Pull request review/approval and comment on code (#3748)
* Initial ui components for pull request review * Add Review Add IssueComment types Signed-off-by: Jonas Franz <info@jonasfranz.software> (cherry picked from commit 2b4daab) Signed-off-by: Jonas Franz <info@jonasfranz.software> * Replace ReviewComment with Content Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add load functions Add ReviewID to findComments Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add create review comment implementation Add migration for review Other small changes Signed-off-by: Jonas Franz <info@jonasfranz.software> * Simplified create and find functions for review Signed-off-by: Jonas Franz <info@jonasfranz.software> * Moved "Pending" to first position Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add GetCurrentReview to simplify fetching current review Signed-off-by: Jonas Franz <info@jonasfranz.software> * Preview for listing comments Signed-off-by: Jonas Franz <info@jonasfranz.software> * Move new comment form to its own file Signed-off-by: Jonas Franz <info@jonasfranz.software> * Implement Review form Show Review comments on comment stream Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add support for single comments Showing buttons in context Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add pending tag to pending review comments Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add unit tests for Review Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fetch all review ids at once Add unit tests Signed-off-by: Jonas Franz <info@jonasfranz.software> * gofmt Signed-off-by: Jonas Franz <info@jonasfranz.software> * Improved comment rendering in "Files" view by adding Comments to DiffLine Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add support for invalidating comments Signed-off-by: Jonas Franz <info@jonasfranz.software> * Switched back to code.gitea.io/git Signed-off-by: Jonas Franz <info@jonasfranz.software> * Moved review migration from v64 to v65 Signed-off-by: Jonas Franz <info@jonasfranz.software> * Rebuild css Signed-off-by: Jonas Franz <info@jonasfranz.software> * gofmt Signed-off-by: Jonas Franz <info@jonasfranz.software> * Improve translations Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix unit tests by updating fixtures and updating outdated test Signed-off-by: Jonas Franz <info@jonasfranz.software> * Comments will be shown at the right place now Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add support for deleting CodeComments Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix problems caused by files in subdirectories Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add support for showing code comments of reviews in conversation Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add support for "Show/Hide outdated" Signed-off-by: Jonas Franz <info@jonasfranz.software> * Update code.gitea.io/git Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add support for new webhooks Signed-off-by: Jonas Franz <info@jonasfranz.software> * Update comparison Signed-off-by: Jonas Franz <info@jonasfranz.software> * Resolve conflicts Signed-off-by: Jonas Franz <info@jonasfranz.software> * Minor UI improvements * update code.gitea.io/git * Fix ui bug reported by @lunny causing wrong position of add button Add functionality to "Cancel" button Add scale effects to add button Hide "Cancel" button for existing comments Signed-off-by: Jonas Franz <info@jonasfranz.software> * Prepare solving conflicts Signed-off-by: Jonas Franz <info@jonasfranz.software> * Show add button only if no comments already exist for the line Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add missing vendor files Signed-off-by: Jonas Franz <info@jonasfranz.software> * Check if reviewer is nil Signed-off-by: Jonas Franz <info@jonasfranz.software> * Show forms only to users who are logged in Signed-off-by: Jonas Franz <info@jonasfranz.software> * Revert "Show forms only to users who are logged in" This reverts commit c083682 Signed-off-by: Jonas Franz <info@jonasfranz.software> * Save patch in comment Render patch for code comments Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add link to comment in code Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add reply form to comment list Show forms only to signed in users Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add 'Reply' as translatable Add CODE_COMMENT_LINES setting Signed-off-by: Jonas Franz <info@jonasfranz.software> * gofmt Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix problems introduced by checking for singed in user Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add v70 Signed-off-by: Jonas Franz <info@jonasfranz.software> * Update generated stylesheet Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix preview Beginn with new review comment patch system Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add new algo to generate diff for line range Remove old algo used for cutting big diffs (it was very buggy) * Add documentation and example for CutDiffAroundLine * Fix example of CutDiffAroundLine * Fix some comment UI rendering bugs * Add code comment edit mode * Send notifications / actions to users until review gets published Fix diff generation bug Fix wrong hashtag * Fix vet errors * Send notifications also for single comments * Fix some notification bugs, fix link * Fix: add comment icon is only shown on code lines * Add lint comment * Add unit tests for git diff * Add more error messages * Regenerated css Signed-off-by: Jonas Franz <info@jonasfranz.software> * fmt Signed-off-by: Jonas Franz <info@jonasfranz.software> * Regenerated CSS with latest less version Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix test by updating comment type to new ID Signed-off-by: Jonas Franz <info@jonasfranz.software> * Introducing CodeComments as type for map[string]map[int64][]*Comment Other minor code improvements Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix data-tab issues Signed-off-by: Jonas Franz <info@jonasfranz.software> * Remove unnecessary change Signed-off-by: Jonas Franz <info@jonasfranz.software> * refactored checkForInvalidation Signed-off-by: Jonas Franz <info@jonasfranz.software> * Append comments instead of setting Signed-off-by: Jonas Franz <info@jonasfranz.software> * Use HeadRepo instead of BaseRepo Signed-off-by: Jonas Franz <info@jonasfranz.software> * Update migration Signed-off-by: Jonas Franz <info@jonasfranz.de> * Regenerated CSS Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add copyright Signed-off-by: Jonas Franz <info@jonasfranz.software> * Update index.css Signed-off-by: Jonas Franz <info@jonasfranz.software>
This commit is contained in:
		
							parent
							
								
									9c354a539a
								
							
						
					
					
						commit
						6e64f9db8e
					
				
					 40 changed files with 2010 additions and 89 deletions
				
			
		|  | @ -69,6 +69,8 @@ ISSUE_PAGING_NUM = 10 | ||||||
| FEED_MAX_COMMIT_NUM = 5 | FEED_MAX_COMMIT_NUM = 5 | ||||||
| ; Number of maximum commits displayed in commit graph. | ; Number of maximum commits displayed in commit graph. | ||||||
| GRAPH_MAX_COMMIT_NUM = 100 | GRAPH_MAX_COMMIT_NUM = 100 | ||||||
|  | ; Number of line of codes shown for a code comment | ||||||
|  | CODE_COMMENT_LINES = 4 | ||||||
| ; Value of `theme-color` meta tag, used by Android >= 5.0 | ; Value of `theme-color` meta tag, used by Android >= 5.0 | ||||||
| ; An invalid color like "none" or "disable" will have the default style | ; An invalid color like "none" or "disable" will have the default style | ||||||
| ; More info: https://developers.google.com/web/updates/2014/11/Support-for-theme-color-in-Chrome-39-for-Android | ; More info: https://developers.google.com/web/updates/2014/11/Support-for-theme-color-in-Chrome-39-for-Android | ||||||
|  |  | ||||||
|  | @ -92,8 +92,8 @@ _Symbols used in table:_ | ||||||
| | Pull/Merge requests | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | Pull/Merge requests | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ||||||
| | Squash merging | ✓ | ✘ | ✓ | ✘ | ✓ | ✓ | ✓ | | | Squash merging | ✓ | ✘ | ✓ | ✘ | ✓ | ✓ | ✓ | | ||||||
| | Rebase merging | ✓ | ✓ | ✓ | ✘ | ⁄ | ✘ | ✓ | | | Rebase merging | ✓ | ✓ | ✓ | ✘ | ⁄ | ✘ | ✓ | | ||||||
| | Pull/Merge request inline comments | ✘ | ✘ | ✓ | ✓ | ✓ | ✓ | ✓ | | | Pull/Merge request inline comments | ✓ | ✘ | ✓ | ✓ | ✓ | ✓ | ✓ | | ||||||
| | Pull/Merge request approval | ✘ | ✘ | ⁄ | ✓ | ✓ | ✓ | ✓ | | | Pull/Merge request approval | ✓ | ✘ | ⁄ | ✓ | ✓ | ✓ | ✓ | | ||||||
| | Merge conflict resolution | ✘ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | | | Merge conflict resolution | ✘ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | | ||||||
| | Restrict push and merge access to certain users | ✓ | ✘ | ✓ | ⁄ | ✓ | ✓ | ✓ | | | Restrict push and merge access to certain users | ✓ | ✘ | ✓ | ⁄ | ✓ | ✓ | ✓ | | ||||||
| | Revert specific commits or a merge request | ✘ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | | | Revert specific commits or a merge request | ✘ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | | ||||||
|  |  | ||||||
|  | @ -1344,3 +1344,25 @@ func IsErrUnknownDependencyType(err error) bool { | ||||||
| func (err ErrUnknownDependencyType) Error() string { | func (err ErrUnknownDependencyType) Error() string { | ||||||
| 	return fmt.Sprintf("unknown dependency type [type: %d]", err.Type) | 	return fmt.Sprintf("unknown dependency type [type: %d]", err.Type) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | //  __________            .__
 | ||||||
|  | //  \______   \ _______  _|__| ______  _  __
 | ||||||
|  | //  |       _// __ \  \/ /  |/ __ \ \/ \/ /
 | ||||||
|  | //  |    |   \  ___/\   /|  \  ___/\     /
 | ||||||
|  | //  |____|_  /\___  >\_/ |__|\___  >\/\_/
 | ||||||
|  | //  \/     \/             \/
 | ||||||
|  | 
 | ||||||
|  | // ErrReviewNotExist represents a "ReviewNotExist" kind of error.
 | ||||||
|  | type ErrReviewNotExist struct { | ||||||
|  | 	ID int64 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // IsErrReviewNotExist checks if an error is a ErrReviewNotExist.
 | ||||||
|  | func IsErrReviewNotExist(err error) bool { | ||||||
|  | 	_, ok := err.(ErrReviewNotExist) | ||||||
|  | 	return ok | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (err ErrReviewNotExist) Error() string { | ||||||
|  | 	return fmt.Sprintf("review does not exist [id: %d]", err.ID) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -20,3 +20,24 @@ | ||||||
|   issue_id: 1 # in repo_id 1 |   issue_id: 1 # in repo_id 1 | ||||||
|   content: "meh..." |   content: "meh..." | ||||||
|   created_unix: 946684812 |   created_unix: 946684812 | ||||||
|  | - | ||||||
|  |   id: 4 | ||||||
|  |   type: 21 # code comment | ||||||
|  |   poster_id: 1 | ||||||
|  |   issue_id: 2 | ||||||
|  |   content: "meh..." | ||||||
|  |   review_id: 4 | ||||||
|  |   line: 4 | ||||||
|  |   tree_path: "README.md" | ||||||
|  |   created_unix: 946684812 | ||||||
|  |   invalidated: false | ||||||
|  | - | ||||||
|  |   id: 5 | ||||||
|  |   type: 21 # code comment | ||||||
|  |   poster_id: 1 | ||||||
|  |   issue_id: 2 | ||||||
|  |   content: "meh..." | ||||||
|  |   line: -4 | ||||||
|  |   tree_path: "README.md" | ||||||
|  |   created_unix: 946684812 | ||||||
|  |   invalidated: false | ||||||
|  |  | ||||||
							
								
								
									
										32
									
								
								models/fixtures/review.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								models/fixtures/review.yml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | ||||||
|  | - | ||||||
|  |   id: 1 | ||||||
|  |   type: 1 | ||||||
|  |   reviewer_id: 1 | ||||||
|  |   issue_id: 2 | ||||||
|  |   content: "Demo Review" | ||||||
|  |   updated_unix: 946684810 | ||||||
|  |   created_unix: 946684810 | ||||||
|  | - | ||||||
|  |   id: 2 | ||||||
|  |   type: 1 | ||||||
|  |   reviewer_id: 534543 | ||||||
|  |   issue_id: 534543 | ||||||
|  |   content: "Invalid Review #1" | ||||||
|  |   updated_unix: 946684810 | ||||||
|  |   created_unix: 946684810 | ||||||
|  | - | ||||||
|  |   id: 3 | ||||||
|  |   type: 1 | ||||||
|  |   reviewer_id: 1 | ||||||
|  |   issue_id: 343545 | ||||||
|  |   content: "Invalid Review #2" | ||||||
|  |   updated_unix: 946684810 | ||||||
|  |   created_unix: 946684810 | ||||||
|  | - | ||||||
|  |   id: 4 | ||||||
|  |   type: 0 # Pending review | ||||||
|  |   reviewer_id: 1 | ||||||
|  |   issue_id: 2 | ||||||
|  |   content: "Pending Review" | ||||||
|  |   updated_unix: 946684810 | ||||||
|  |   created_unix: 946684810 | ||||||
|  | @ -14,6 +14,8 @@ import ( | ||||||
| 	"io/ioutil" | 	"io/ioutil" | ||||||
| 	"os" | 	"os" | ||||||
| 	"os/exec" | 	"os/exec" | ||||||
|  | 	"regexp" | ||||||
|  | 	"sort" | ||||||
| 	"strconv" | 	"strconv" | ||||||
| 	"strings" | 	"strings" | ||||||
| 
 | 
 | ||||||
|  | @ -57,6 +59,7 @@ type DiffLine struct { | ||||||
| 	RightIdx int | 	RightIdx int | ||||||
| 	Type     DiffLineType | 	Type     DiffLineType | ||||||
| 	Content  string | 	Content  string | ||||||
|  | 	Comments []*Comment | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // GetType returns the type of a DiffLine.
 | // GetType returns the type of a DiffLine.
 | ||||||
|  | @ -64,6 +67,19 @@ func (d *DiffLine) GetType() int { | ||||||
| 	return int(d.Type) | 	return int(d.Type) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // CanComment returns whether or not a line can get commented
 | ||||||
|  | func (d *DiffLine) CanComment() bool { | ||||||
|  | 	return len(d.Comments) == 0 && d.Type != DiffLineSection | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetCommentSide returns the comment side of the first comment, if not set returns empty string
 | ||||||
|  | func (d *DiffLine) GetCommentSide() string { | ||||||
|  | 	if len(d.Comments) == 0 { | ||||||
|  | 		return "" | ||||||
|  | 	} | ||||||
|  | 	return d.Comments[0].DiffSide() | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // DiffSection represents a section of a DiffFile.
 | // DiffSection represents a section of a DiffFile.
 | ||||||
| type DiffSection struct { | type DiffSection struct { | ||||||
| 	Name  string | 	Name  string | ||||||
|  | @ -225,11 +241,167 @@ type Diff struct { | ||||||
| 	IsIncomplete                 bool | 	IsIncomplete                 bool | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // LoadComments loads comments into each line
 | ||||||
|  | func (diff *Diff) LoadComments(issue *Issue, currentUser *User) error { | ||||||
|  | 	allComments, err := FetchCodeComments(issue, currentUser) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	for _, file := range diff.Files { | ||||||
|  | 		if lineCommits, ok := allComments[file.Name]; ok { | ||||||
|  | 			for _, section := range file.Sections { | ||||||
|  | 				for _, line := range section.Lines { | ||||||
|  | 					if comments, ok := lineCommits[int64(line.LeftIdx*-1)]; ok { | ||||||
|  | 						line.Comments = append(line.Comments, comments...) | ||||||
|  | 					} | ||||||
|  | 					if comments, ok := lineCommits[int64(line.RightIdx)]; ok { | ||||||
|  | 						line.Comments = append(line.Comments, comments...) | ||||||
|  | 					} | ||||||
|  | 					sort.SliceStable(line.Comments, func(i, j int) bool { | ||||||
|  | 						return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix | ||||||
|  | 					}) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // NumFiles returns number of files changes in a diff.
 | // NumFiles returns number of files changes in a diff.
 | ||||||
| func (diff *Diff) NumFiles() int { | func (diff *Diff) NumFiles() int { | ||||||
| 	return len(diff.Files) | 	return len(diff.Files) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // Example: @@ -1,8 +1,9 @@ => [..., 1, 8, 1, 9]
 | ||||||
|  | var hunkRegex = regexp.MustCompile(`^@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@`) | ||||||
|  | 
 | ||||||
|  | func isHeader(lof string) bool { | ||||||
|  | 	return strings.HasPrefix(lof, cmdDiffHead) || strings.HasPrefix(lof, "---") || strings.HasPrefix(lof, "+++") | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CutDiffAroundLine cuts a diff of a file in way that only the given line + numberOfLine above it will be shown
 | ||||||
|  | // it also recalculates hunks and adds the appropriate headers to the new diff.
 | ||||||
|  | // Warning: Only one-file diffs are allowed.
 | ||||||
|  | func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLine int) string { | ||||||
|  | 	if line == 0 || numbersOfLine == 0 { | ||||||
|  | 		// no line or num of lines => no diff
 | ||||||
|  | 		return "" | ||||||
|  | 	} | ||||||
|  | 	scanner := bufio.NewScanner(originalDiff) | ||||||
|  | 	hunk := make([]string, 0) | ||||||
|  | 	// begin is the start of the hunk containing searched line
 | ||||||
|  | 	// end is the end of the hunk ...
 | ||||||
|  | 	// currentLine is the line number on the side of the searched line (differentiated by old)
 | ||||||
|  | 	// otherLine is the line number on the opposite side of the searched line (differentiated by old)
 | ||||||
|  | 	var begin, end, currentLine, otherLine int64 | ||||||
|  | 	var headerLines int | ||||||
|  | 	for scanner.Scan() { | ||||||
|  | 		lof := scanner.Text() | ||||||
|  | 		// Add header to enable parsing
 | ||||||
|  | 		if isHeader(lof) { | ||||||
|  | 			hunk = append(hunk, lof) | ||||||
|  | 			headerLines++ | ||||||
|  | 		} | ||||||
|  | 		if currentLine > line { | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 		// Detect "hunk" with contains commented lof
 | ||||||
|  | 		if strings.HasPrefix(lof, "@@") { | ||||||
|  | 			// Already got our hunk. End of hunk detected!
 | ||||||
|  | 			if len(hunk) > headerLines { | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 			groups := hunkRegex.FindStringSubmatch(lof) | ||||||
|  | 			if old { | ||||||
|  | 				begin = com.StrTo(groups[1]).MustInt64() | ||||||
|  | 				end = com.StrTo(groups[2]).MustInt64() | ||||||
|  | 				// init otherLine with begin of opposite side
 | ||||||
|  | 				otherLine = com.StrTo(groups[3]).MustInt64() | ||||||
|  | 			} else { | ||||||
|  | 				begin = com.StrTo(groups[3]).MustInt64() | ||||||
|  | 				end = com.StrTo(groups[4]).MustInt64() | ||||||
|  | 				// init otherLine with begin of opposite side
 | ||||||
|  | 				otherLine = com.StrTo(groups[1]).MustInt64() | ||||||
|  | 			} | ||||||
|  | 			end += begin // end is for real only the number of lines in hunk
 | ||||||
|  | 			// lof is between begin and end
 | ||||||
|  | 			if begin <= line && end >= line { | ||||||
|  | 				hunk = append(hunk, lof) | ||||||
|  | 				currentLine = begin | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 		} else if len(hunk) > headerLines { | ||||||
|  | 			hunk = append(hunk, lof) | ||||||
|  | 			// Count lines in context
 | ||||||
|  | 			switch lof[0] { | ||||||
|  | 			case '+': | ||||||
|  | 				if !old { | ||||||
|  | 					currentLine++ | ||||||
|  | 				} else { | ||||||
|  | 					otherLine++ | ||||||
|  | 				} | ||||||
|  | 			case '-': | ||||||
|  | 				if old { | ||||||
|  | 					currentLine++ | ||||||
|  | 				} else { | ||||||
|  | 					otherLine++ | ||||||
|  | 				} | ||||||
|  | 			default: | ||||||
|  | 				currentLine++ | ||||||
|  | 				otherLine++ | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// No hunk found
 | ||||||
|  | 	if currentLine == 0 { | ||||||
|  | 		return "" | ||||||
|  | 	} | ||||||
|  | 	// headerLines + hunkLine (1) = totalNonCodeLines
 | ||||||
|  | 	if len(hunk)-headerLines-1 <= numbersOfLine { | ||||||
|  | 		// No need to cut the hunk => return existing hunk
 | ||||||
|  | 		return strings.Join(hunk, "\n") | ||||||
|  | 	} | ||||||
|  | 	var oldBegin, oldNumOfLines, newBegin, newNumOfLines int64 | ||||||
|  | 	if old { | ||||||
|  | 		oldBegin = currentLine | ||||||
|  | 		newBegin = otherLine | ||||||
|  | 	} else { | ||||||
|  | 		oldBegin = otherLine | ||||||
|  | 		newBegin = currentLine | ||||||
|  | 	} | ||||||
|  | 	// headers + hunk header
 | ||||||
|  | 	newHunk := make([]string, headerLines) | ||||||
|  | 	// transfer existing headers
 | ||||||
|  | 	for idx, lof := range hunk[:headerLines] { | ||||||
|  | 		newHunk[idx] = lof | ||||||
|  | 	} | ||||||
|  | 	// transfer last n lines
 | ||||||
|  | 	for _, lof := range hunk[len(hunk)-numbersOfLine-1:] { | ||||||
|  | 		newHunk = append(newHunk, lof) | ||||||
|  | 	} | ||||||
|  | 	// calculate newBegin, ... by counting lines
 | ||||||
|  | 	for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- { | ||||||
|  | 		switch hunk[i][0] { | ||||||
|  | 		case '+': | ||||||
|  | 			newBegin-- | ||||||
|  | 			newNumOfLines++ | ||||||
|  | 		case '-': | ||||||
|  | 			oldBegin-- | ||||||
|  | 			oldNumOfLines++ | ||||||
|  | 		default: | ||||||
|  | 			oldBegin-- | ||||||
|  | 			newBegin-- | ||||||
|  | 			newNumOfLines++ | ||||||
|  | 			oldNumOfLines++ | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	// construct the new hunk header
 | ||||||
|  | 	newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@", | ||||||
|  | 		oldBegin, oldNumOfLines, newBegin, newNumOfLines) | ||||||
|  | 	return strings.Join(newHunk, "\n") | ||||||
|  | } | ||||||
|  | 
 | ||||||
| const cmdDiffHead = "diff --git " | const cmdDiffHead = "diff --git " | ||||||
| 
 | 
 | ||||||
| // ParsePatch builds a Diff object from a io.Reader and some
 | // ParsePatch builds a Diff object from a io.Reader and some
 | ||||||
|  | @ -307,7 +479,6 @@ func ParsePatch(maxLines, maxLineCharacters, maxFiles int, reader io.Reader) (*D | ||||||
| 		if curFileLinesCount >= maxLines { | 		if curFileLinesCount >= maxLines { | ||||||
| 			curFile.IsIncomplete = true | 			curFile.IsIncomplete = true | ||||||
| 		} | 		} | ||||||
| 
 |  | ||||||
| 		switch { | 		switch { | ||||||
| 		case line[0] == ' ': | 		case line[0] == ' ': | ||||||
| 			diffLine := &DiffLine{Type: DiffLinePlain, Content: line, LeftIdx: leftLine, RightIdx: rightLine} | 			diffLine := &DiffLine{Type: DiffLinePlain, Content: line, LeftIdx: leftLine, RightIdx: rightLine} | ||||||
|  | @ -524,32 +695,46 @@ const ( | ||||||
| // GetRawDiff dumps diff results of repository in given commit ID to io.Writer.
 | // GetRawDiff dumps diff results of repository in given commit ID to io.Writer.
 | ||||||
| // TODO: move this function to gogits/git-module
 | // TODO: move this function to gogits/git-module
 | ||||||
| func GetRawDiff(repoPath, commitID string, diffType RawDiffType, writer io.Writer) error { | func GetRawDiff(repoPath, commitID string, diffType RawDiffType, writer io.Writer) error { | ||||||
|  | 	return GetRawDiffForFile(repoPath, "", commitID, diffType, "", writer) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetRawDiffForFile dumps diff results of file in given commit ID to io.Writer.
 | ||||||
|  | // TODO: move this function to gogits/git-module
 | ||||||
|  | func GetRawDiffForFile(repoPath, startCommit, endCommit string, diffType RawDiffType, file string, writer io.Writer) error { | ||||||
| 	repo, err := git.OpenRepository(repoPath) | 	repo, err := git.OpenRepository(repoPath) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("OpenRepository: %v", err) | 		return fmt.Errorf("OpenRepository: %v", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	commit, err := repo.GetCommit(commitID) | 	commit, err := repo.GetCommit(endCommit) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("GetCommit: %v", err) | 		return fmt.Errorf("GetCommit: %v", err) | ||||||
| 	} | 	} | ||||||
| 
 | 	fileArgs := make([]string, 0) | ||||||
|  | 	if len(file) > 0 { | ||||||
|  | 		fileArgs = append(fileArgs, "--", file) | ||||||
|  | 	} | ||||||
| 	var cmd *exec.Cmd | 	var cmd *exec.Cmd | ||||||
| 	switch diffType { | 	switch diffType { | ||||||
| 	case RawDiffNormal: | 	case RawDiffNormal: | ||||||
| 		if commit.ParentCount() == 0 { | 		if len(startCommit) != 0 { | ||||||
| 			cmd = exec.Command("git", "show", commitID) | 			cmd = exec.Command("git", append([]string{"diff", "-M", startCommit, endCommit}, fileArgs...)...) | ||||||
|  | 		} else if commit.ParentCount() == 0 { | ||||||
|  | 			cmd = exec.Command("git", append([]string{"show", endCommit}, fileArgs...)...) | ||||||
| 		} else { | 		} else { | ||||||
| 			c, _ := commit.Parent(0) | 			c, _ := commit.Parent(0) | ||||||
| 			cmd = exec.Command("git", "diff", "-M", c.ID.String(), commitID) | 			cmd = exec.Command("git", append([]string{"diff", "-M", c.ID.String(), endCommit}, fileArgs...)...) | ||||||
| 		} | 		} | ||||||
| 	case RawDiffPatch: | 	case RawDiffPatch: | ||||||
| 		if commit.ParentCount() == 0 { | 		if len(startCommit) != 0 { | ||||||
| 			cmd = exec.Command("git", "format-patch", "--no-signature", "--stdout", "--root", commitID) | 			query := fmt.Sprintf("%s...%s", endCommit, startCommit) | ||||||
|  | 			cmd = exec.Command("git", append([]string{"format-patch", "--no-signature", "--stdout", "--root", query}, fileArgs...)...) | ||||||
|  | 		} else if commit.ParentCount() == 0 { | ||||||
|  | 			cmd = exec.Command("git", append([]string{"format-patch", "--no-signature", "--stdout", "--root", endCommit}, fileArgs...)...) | ||||||
| 		} else { | 		} else { | ||||||
| 			c, _ := commit.Parent(0) | 			c, _ := commit.Parent(0) | ||||||
| 			query := fmt.Sprintf("%s...%s", commitID, c.ID.String()) | 			query := fmt.Sprintf("%s...%s", endCommit, c.ID.String()) | ||||||
| 			cmd = exec.Command("git", "format-patch", "--no-signature", "--stdout", query) | 			cmd = exec.Command("git", append([]string{"format-patch", "--no-signature", "--stdout", query}, fileArgs...)...) | ||||||
| 		} | 		} | ||||||
| 	default: | 	default: | ||||||
| 		return fmt.Errorf("invalid diffType: %s", diffType) | 		return fmt.Errorf("invalid diffType: %s", diffType) | ||||||
|  | @ -560,7 +745,6 @@ func GetRawDiff(repoPath, commitID string, diffType RawDiffType, writer io.Write | ||||||
| 	cmd.Dir = repoPath | 	cmd.Dir = repoPath | ||||||
| 	cmd.Stdout = writer | 	cmd.Stdout = writer | ||||||
| 	cmd.Stderr = stderr | 	cmd.Stderr = stderr | ||||||
| 
 |  | ||||||
| 	if err = cmd.Run(); err != nil { | 	if err = cmd.Run(); err != nil { | ||||||
| 		return fmt.Errorf("Run: %v - %s", err, stderr) | 		return fmt.Errorf("Run: %v - %s", err, stderr) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -2,9 +2,11 @@ package models | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"html/template" | 	"html/template" | ||||||
|  | 	"strings" | ||||||
| 	"testing" | 	"testing" | ||||||
| 
 | 
 | ||||||
| 	dmp "github.com/sergi/go-diff/diffmatchpatch" | 	dmp "github.com/sergi/go-diff/diffmatchpatch" | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func assertEqual(t *testing.T, s1 string, s2 template.HTML) { | func assertEqual(t *testing.T, s1 string, s2 template.HTML) { | ||||||
|  | @ -34,3 +36,106 @@ func TestDiffToHTML(t *testing.T) { | ||||||
| 		{Type: dmp.DiffEqual, Text: " biz"}, | 		{Type: dmp.DiffEqual, Text: " biz"}, | ||||||
| 	}, DiffLineDel)) | 	}, DiffLineDel)) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | const exampleDiff = `diff --git a/README.md b/README.md | ||||||
|  | --- a/README.md | ||||||
|  | +++ b/README.md | ||||||
|  | @@ -1,3 +1,6 @@ | ||||||
|  |  # gitea-github-migrator | ||||||
|  | + | ||||||
|  | + Build Status | ||||||
|  | - Latest Release | ||||||
|  |  Docker Pulls | ||||||
|  | + cut off | ||||||
|  | + cut off` | ||||||
|  | 
 | ||||||
|  | func TestCutDiffAroundLine(t *testing.T) { | ||||||
|  | 	result := CutDiffAroundLine(strings.NewReader(exampleDiff), 4, false, 3) | ||||||
|  | 	resultByLine := strings.Split(result, "\n") | ||||||
|  | 	assert.Len(t, resultByLine, 7) | ||||||
|  | 	// Check if headers got transferred
 | ||||||
|  | 	assert.Equal(t, "diff --git a/README.md b/README.md", resultByLine[0]) | ||||||
|  | 	assert.Equal(t, "--- a/README.md", resultByLine[1]) | ||||||
|  | 	assert.Equal(t, "+++ b/README.md", resultByLine[2]) | ||||||
|  | 	// Check if hunk header is calculated correctly
 | ||||||
|  | 	assert.Equal(t, "@@ -2,2 +3,2 @@", resultByLine[3]) | ||||||
|  | 	// Check if line got transferred
 | ||||||
|  | 	assert.Equal(t, "+ Build Status", resultByLine[4]) | ||||||
|  | 
 | ||||||
|  | 	// Must be same result as before since old line 3 == new line 5
 | ||||||
|  | 	newResult := CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3) | ||||||
|  | 	assert.Equal(t, result, newResult, "Must be same result as before since old line 3 == new line 5") | ||||||
|  | 
 | ||||||
|  | 	newResult = CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 300) | ||||||
|  | 	assert.Equal(t, exampleDiff, newResult) | ||||||
|  | 
 | ||||||
|  | 	emptyResult := CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 0) | ||||||
|  | 	assert.Empty(t, emptyResult) | ||||||
|  | 
 | ||||||
|  | 	// Line is out of scope
 | ||||||
|  | 	emptyResult = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0) | ||||||
|  | 	assert.Empty(t, emptyResult) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func BenchmarkCutDiffAroundLine(b *testing.B) { | ||||||
|  | 	for n := 0; n < b.N; n++ { | ||||||
|  | 		CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func ExampleCutDiffAroundLine() { | ||||||
|  | 	const diff = `diff --git a/README.md b/README.md | ||||||
|  | --- a/README.md | ||||||
|  | +++ b/README.md | ||||||
|  | @@ -1,3 +1,6 @@ | ||||||
|  |  # gitea-github-migrator | ||||||
|  | + | ||||||
|  | + Build Status | ||||||
|  | - Latest Release | ||||||
|  |  Docker Pulls | ||||||
|  | + cut off | ||||||
|  | + cut off` | ||||||
|  | 	result := CutDiffAroundLine(strings.NewReader(diff), 4, false, 3) | ||||||
|  | 	println(result) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func setupDefaultDiff() *Diff { | ||||||
|  | 	return &Diff{ | ||||||
|  | 		Files: []*DiffFile{ | ||||||
|  | 			{ | ||||||
|  | 				Name: "README.md", | ||||||
|  | 				Sections: []*DiffSection{ | ||||||
|  | 					{ | ||||||
|  | 						Lines: []*DiffLine{ | ||||||
|  | 							{ | ||||||
|  | 								LeftIdx:  4, | ||||||
|  | 								RightIdx: 4, | ||||||
|  | 							}, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | func TestDiff_LoadComments(t *testing.T) { | ||||||
|  | 	issue := AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue) | ||||||
|  | 	user := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User) | ||||||
|  | 	diff := setupDefaultDiff() | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	assert.NoError(t, diff.LoadComments(issue, user)) | ||||||
|  | 	assert.Len(t, diff.Files[0].Sections[0].Lines[0].Comments, 2) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestDiffLine_CanComment(t *testing.T) { | ||||||
|  | 	assert.False(t, (&DiffLine{Type: DiffLineSection}).CanComment()) | ||||||
|  | 	assert.False(t, (&DiffLine{Type: DiffLineAdd, Comments: []*Comment{{Content: "bla"}}}).CanComment()) | ||||||
|  | 	assert.True(t, (&DiffLine{Type: DiffLineAdd}).CanComment()) | ||||||
|  | 	assert.True(t, (&DiffLine{Type: DiffLineDel}).CanComment()) | ||||||
|  | 	assert.True(t, (&DiffLine{Type: DiffLinePlain}).CanComment()) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestDiffLine_GetCommentSide(t *testing.T) { | ||||||
|  | 	assert.Equal(t, "previous", (&DiffLine{Comments: []*Comment{{Line: -3}}}).GetCommentSide()) | ||||||
|  | 	assert.Equal(t, "proposed", (&DiffLine{Comments: []*Comment{{Line: 3}}}).GetCommentSide()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -1,13 +1,19 @@ | ||||||
| // Copyright 2016 The Gogs Authors. All rights reserved.
 | // Copyright 2018 The Gitea Authors.
 | ||||||
|  | // Copyright 2016 The Gogs Authors.
 | ||||||
|  | // All rights reserved.
 | ||||||
| // Use of this source code is governed by a MIT-style
 | // Use of this source code is governed by a MIT-style
 | ||||||
| // license that can be found in the LICENSE file.
 | // license that can be found in the LICENSE file.
 | ||||||
| 
 | 
 | ||||||
| package models | package models | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"bytes" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"strings" | 	"strings" | ||||||
| 
 | 
 | ||||||
|  | 	"code.gitea.io/git" | ||||||
|  | 	"code.gitea.io/gitea/modules/markup/markdown" | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
| 	"github.com/Unknwon/com" | 	"github.com/Unknwon/com" | ||||||
| 	"github.com/go-xorm/builder" | 	"github.com/go-xorm/builder" | ||||||
| 	"github.com/go-xorm/xorm" | 	"github.com/go-xorm/xorm" | ||||||
|  | @ -70,6 +76,10 @@ const ( | ||||||
| 	CommentTypeAddDependency | 	CommentTypeAddDependency | ||||||
| 	//Dependency removed
 | 	//Dependency removed
 | ||||||
| 	CommentTypeRemoveDependency | 	CommentTypeRemoveDependency | ||||||
|  | 	// Comment a line of code
 | ||||||
|  | 	CommentTypeCode | ||||||
|  | 	// Reviews a pull request by giving general feedback
 | ||||||
|  | 	CommentTypeReview | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // CommentTag defines comment tag type
 | // CommentTag defines comment tag type
 | ||||||
|  | @ -106,10 +116,14 @@ type Comment struct { | ||||||
| 	DependentIssue   *Issue `xorm:"-"` | 	DependentIssue   *Issue `xorm:"-"` | ||||||
| 
 | 
 | ||||||
| 	CommitID        int64 | 	CommitID        int64 | ||||||
| 	Line            int64 | 	Line            int64 // - previous line / + proposed line
 | ||||||
|  | 	TreePath        string | ||||||
| 	Content         string `xorm:"TEXT"` | 	Content         string `xorm:"TEXT"` | ||||||
| 	RenderedContent string `xorm:"-"` | 	RenderedContent string `xorm:"-"` | ||||||
| 
 | 
 | ||||||
|  | 	// Path represents the 4 lines of code cemented by this comment
 | ||||||
|  | 	Patch string `xorm:"TEXT"` | ||||||
|  | 
 | ||||||
| 	CreatedUnix util.TimeStamp `xorm:"INDEX created"` | 	CreatedUnix util.TimeStamp `xorm:"INDEX created"` | ||||||
| 	UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` | 	UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` | ||||||
| 
 | 
 | ||||||
|  | @ -121,6 +135,10 @@ type Comment struct { | ||||||
| 
 | 
 | ||||||
| 	// For view issue page.
 | 	// For view issue page.
 | ||||||
| 	ShowTag CommentTag `xorm:"-"` | 	ShowTag CommentTag `xorm:"-"` | ||||||
|  | 
 | ||||||
|  | 	Review      *Review `xorm:"-"` | ||||||
|  | 	ReviewID    int64 | ||||||
|  | 	Invalidated bool | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // LoadIssue loads issue from database
 | // LoadIssue loads issue from database
 | ||||||
|  | @ -171,6 +189,20 @@ func (c *Comment) HTMLURL() string { | ||||||
| 		log.Error(4, "LoadIssue(%d): %v", c.IssueID, err) | 		log.Error(4, "LoadIssue(%d): %v", c.IssueID, err) | ||||||
| 		return "" | 		return "" | ||||||
| 	} | 	} | ||||||
|  | 	if c.Type == CommentTypeCode { | ||||||
|  | 		if c.ReviewID == 0 { | ||||||
|  | 			return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) | ||||||
|  | 		} | ||||||
|  | 		if c.Review == nil { | ||||||
|  | 			if err := c.LoadReview(); err != nil { | ||||||
|  | 				log.Warn("LoadReview(%d): %v", c.ReviewID, err) | ||||||
|  | 				return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		if c.Review.Type <= ReviewTypePending { | ||||||
|  | 			return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
| 	return fmt.Sprintf("%s#%s", c.Issue.HTMLURL(), c.HashTag()) | 	return fmt.Sprintf("%s#%s", c.Issue.HTMLURL(), c.HashTag()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -342,6 +374,89 @@ func (c *Comment) LoadReactions() error { | ||||||
| 	return c.loadReactions(x) | 	return c.loadReactions(x) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c *Comment) loadReview(e Engine) (err error) { | ||||||
|  | 	if c.Review, err = getReviewByID(e, c.ReviewID); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // LoadReview loads the associated review
 | ||||||
|  | func (c *Comment) LoadReview() error { | ||||||
|  | 	return c.loadReview(x) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c *Comment) checkInvalidation(e Engine, doer *User, repo *git.Repository, branch string) error { | ||||||
|  | 	// FIXME differentiate between previous and proposed line
 | ||||||
|  | 	commit, err := repo.LineBlame(branch, repo.Path, c.TreePath, uint(c.UnsignedLine())) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if c.CommitSHA != commit.ID.String() { | ||||||
|  | 		c.Invalidated = true | ||||||
|  | 		return UpdateComment(doer, c, "") | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CheckInvalidation checks if the line of code comment got changed by another commit.
 | ||||||
|  | // If the line got changed the comment is going to be invalidated.
 | ||||||
|  | func (c *Comment) CheckInvalidation(repo *git.Repository, doer *User, branch string) error { | ||||||
|  | 	return c.checkInvalidation(x, doer, repo, branch) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes.
 | ||||||
|  | func (c *Comment) DiffSide() string { | ||||||
|  | 	if c.Line < 0 { | ||||||
|  | 		return "previous" | ||||||
|  | 	} | ||||||
|  | 	return "proposed" | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // UnsignedLine returns the LOC of the code comment without + or -
 | ||||||
|  | func (c *Comment) UnsignedLine() uint64 { | ||||||
|  | 	if c.Line < 0 { | ||||||
|  | 		return uint64(c.Line * -1) | ||||||
|  | 	} | ||||||
|  | 	return uint64(c.Line) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // AsDiff returns c.Patch as *Diff
 | ||||||
|  | func (c *Comment) AsDiff() (*Diff, error) { | ||||||
|  | 	diff, err := ParsePatch(setting.Git.MaxGitDiffLines, | ||||||
|  | 		setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(c.Patch)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	if len(diff.Files) == 0 { | ||||||
|  | 		return nil, fmt.Errorf("no file found for comment ID: %d", c.ID) | ||||||
|  | 	} | ||||||
|  | 	secs := diff.Files[0].Sections | ||||||
|  | 	if len(secs) == 0 { | ||||||
|  | 		return nil, fmt.Errorf("no sections found for comment ID: %d", c.ID) | ||||||
|  | 	} | ||||||
|  | 	return diff, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // MustAsDiff executes AsDiff and logs the error instead of returning
 | ||||||
|  | func (c *Comment) MustAsDiff() *Diff { | ||||||
|  | 	diff, err := c.AsDiff() | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Warn("MustAsDiff: %v", err) | ||||||
|  | 	} | ||||||
|  | 	return diff | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CodeCommentURL returns the url to a comment in code
 | ||||||
|  | func (c *Comment) CodeCommentURL() string { | ||||||
|  | 	err := c.LoadIssue() | ||||||
|  | 	if err != nil { // Silently dropping errors :unamused:
 | ||||||
|  | 		log.Error(4, "LoadIssue(%d): %v", c.IssueID, err) | ||||||
|  | 		return "" | ||||||
|  | 	} | ||||||
|  | 	return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err error) { | func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err error) { | ||||||
| 	var LabelID int64 | 	var LabelID int64 | ||||||
| 	if opts.Label != nil { | 	if opts.Label != nil { | ||||||
|  | @ -365,6 +480,9 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | ||||||
| 		OldTitle:         opts.OldTitle, | 		OldTitle:         opts.OldTitle, | ||||||
| 		NewTitle:         opts.NewTitle, | 		NewTitle:         opts.NewTitle, | ||||||
| 		DependentIssueID: opts.DependentIssueID, | 		DependentIssueID: opts.DependentIssueID, | ||||||
|  | 		TreePath:         opts.TreePath, | ||||||
|  | 		ReviewID:         opts.ReviewID, | ||||||
|  | 		Patch:            opts.Patch, | ||||||
| 	} | 	} | ||||||
| 	if _, err = e.Insert(comment); err != nil { | 	if _, err = e.Insert(comment); err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
|  | @ -374,6 +492,14 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	if err = sendCreateCommentAction(e, opts, comment); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return comment, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func sendCreateCommentAction(e *xorm.Session, opts *CreateCommentOptions, comment *Comment) (err error) { | ||||||
| 	// Compose comment action, could be plain comment, close or reopen issue/pull request.
 | 	// Compose comment action, could be plain comment, close or reopen issue/pull request.
 | ||||||
| 	// This object will be used to notify watchers in the end of function.
 | 	// This object will be used to notify watchers in the end of function.
 | ||||||
| 	act := &Action{ | 	act := &Action{ | ||||||
|  | @ -386,14 +512,25 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | ||||||
| 		CommentID: comment.ID, | 		CommentID: comment.ID, | ||||||
| 		IsPrivate: opts.Repo.IsPrivate, | 		IsPrivate: opts.Repo.IsPrivate, | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| 	// Check comment type.
 | 	// Check comment type.
 | ||||||
| 	switch opts.Type { | 	switch opts.Type { | ||||||
|  | 	case CommentTypeCode: | ||||||
|  | 		if comment.ReviewID != 0 { | ||||||
|  | 			if comment.Review == nil { | ||||||
|  | 				if err := comment.LoadReview(); err != nil { | ||||||
|  | 					return err | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			if comment.Review.Type <= ReviewTypePending { | ||||||
|  | 				return nil | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		fallthrough | ||||||
| 	case CommentTypeComment: | 	case CommentTypeComment: | ||||||
| 		act.OpType = ActionCommentIssue | 		act.OpType = ActionCommentIssue | ||||||
| 
 | 
 | ||||||
| 		if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil { | 		if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil { | ||||||
| 			return nil, err | 			return err | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// Check attachments
 | 		// Check attachments
 | ||||||
|  | @ -404,7 +541,7 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | ||||||
| 				if IsErrAttachmentNotExist(err) { | 				if IsErrAttachmentNotExist(err) { | ||||||
| 					continue | 					continue | ||||||
| 				} | 				} | ||||||
| 				return nil, fmt.Errorf("getAttachmentByUUID [%s]: %v", uuid, err) | 				return fmt.Errorf("getAttachmentByUUID [%s]: %v", uuid, err) | ||||||
| 			} | 			} | ||||||
| 			attachments = append(attachments, attach) | 			attachments = append(attachments, attach) | ||||||
| 		} | 		} | ||||||
|  | @ -414,7 +551,7 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | ||||||
| 			attachments[i].CommentID = comment.ID | 			attachments[i].CommentID = comment.ID | ||||||
| 			// No assign value could be 0, so ignore AllCols().
 | 			// No assign value could be 0, so ignore AllCols().
 | ||||||
| 			if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil { | 			if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil { | ||||||
| 				return nil, fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) | 				return fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  | @ -430,7 +567,7 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | ||||||
| 			_, err = e.Exec("UPDATE `repository` SET num_closed_issues=num_closed_issues-1 WHERE id=?", opts.Repo.ID) | 			_, err = e.Exec("UPDATE `repository` SET num_closed_issues=num_closed_issues-1 WHERE id=?", opts.Repo.ID) | ||||||
| 		} | 		} | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			return err | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 	case CommentTypeClose: | 	case CommentTypeClose: | ||||||
|  | @ -445,15 +582,13 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | ||||||
| 			_, err = e.Exec("UPDATE `repository` SET num_closed_issues=num_closed_issues+1 WHERE id=?", opts.Repo.ID) | 			_, err = e.Exec("UPDATE `repository` SET num_closed_issues=num_closed_issues+1 WHERE id=?", opts.Repo.ID) | ||||||
| 		} | 		} | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			return err | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| 	// update the issue's updated_unix column
 | 	// update the issue's updated_unix column
 | ||||||
| 	if err = updateIssueCols(e, opts.Issue, "updated_unix"); err != nil { | 	if err = updateIssueCols(e, opts.Issue, "updated_unix"); err != nil { | ||||||
| 		return nil, err | 		return err | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| 	// Notify watchers for whatever action comes in, ignore if no action type.
 | 	// Notify watchers for whatever action comes in, ignore if no action type.
 | ||||||
| 	if act.OpType > 0 { | 	if act.OpType > 0 { | ||||||
| 		if err = notifyWatchers(e, act); err != nil { | 		if err = notifyWatchers(e, act); err != nil { | ||||||
|  | @ -463,8 +598,7 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err | ||||||
| 			log.Error(4, "MailParticipants: %v", err) | 			log.Error(4, "MailParticipants: %v", err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 	return nil | ||||||
| 	return comment, nil |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func createStatusComment(e *xorm.Session, doer *User, repo *Repository, issue *Issue) (*Comment, error) { | func createStatusComment(e *xorm.Session, doer *User, repo *Repository, issue *Issue) (*Comment, error) { | ||||||
|  | @ -616,7 +750,10 @@ type CreateCommentOptions struct { | ||||||
| 	NewTitle         string | 	NewTitle         string | ||||||
| 	CommitID         int64 | 	CommitID         int64 | ||||||
| 	CommitSHA        string | 	CommitSHA        string | ||||||
|  | 	Patch            string | ||||||
| 	LineNum          int64 | 	LineNum          int64 | ||||||
|  | 	TreePath         string | ||||||
|  | 	ReviewID         int64 | ||||||
| 	Content          string | 	Content          string | ||||||
| 	Attachments      []string // UUIDs of attachments
 | 	Attachments      []string // UUIDs of attachments
 | ||||||
| } | } | ||||||
|  | @ -673,6 +810,58 @@ func CreateIssueComment(doer *User, repo *Repository, issue *Issue, content stri | ||||||
| 	return comment, nil | 	return comment, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // CreateCodeComment creates a plain code comment at the specified line / path
 | ||||||
|  | func CreateCodeComment(doer *User, repo *Repository, issue *Issue, content, treePath string, line, reviewID int64) (*Comment, error) { | ||||||
|  | 	var commitID, patch string | ||||||
|  | 	pr, err := GetPullRequestByIssueID(issue.ID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("GetPullRequestByIssueID: %v", err) | ||||||
|  | 	} | ||||||
|  | 	if err := pr.GetBaseRepo(); err != nil { | ||||||
|  | 		return nil, fmt.Errorf("GetHeadRepo: %v", err) | ||||||
|  | 	} | ||||||
|  | 	gitRepo, err := git.OpenRepository(pr.BaseRepo.RepoPath()) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("OpenRepository: %v", err) | ||||||
|  | 	} | ||||||
|  | 	// FIXME differentiate between previous and proposed line
 | ||||||
|  | 	var gitLine = line | ||||||
|  | 	if gitLine < 0 { | ||||||
|  | 		gitLine *= -1 | ||||||
|  | 	} | ||||||
|  | 	// FIXME validate treePath
 | ||||||
|  | 	// Get latest commit referencing the commented line
 | ||||||
|  | 	commit, err := gitRepo.LineBlame(pr.GetGitRefName(), gitRepo.Path, treePath, uint(gitLine)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %v", pr.GetGitRefName(), gitRepo.Path, treePath, gitLine, err) | ||||||
|  | 	} | ||||||
|  | 	// Only fetch diff if comment is review comment
 | ||||||
|  | 	if reviewID != 0 { | ||||||
|  | 		headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName()) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, fmt.Errorf("GetRefCommitID[%s]: %v", pr.GetGitRefName(), err) | ||||||
|  | 		} | ||||||
|  | 		patchBuf := new(bytes.Buffer) | ||||||
|  | 		if err := GetRawDiffForFile(gitRepo.Path, pr.MergeBase, headCommitID, RawDiffNormal, treePath, patchBuf); err != nil { | ||||||
|  | 			return nil, fmt.Errorf("GetRawDiffForLine[%s, %s, %s, %s]: %v", err, gitRepo.Path, pr.MergeBase, headCommitID, treePath) | ||||||
|  | 		} | ||||||
|  | 		patch = CutDiffAroundLine(strings.NewReader(patchBuf.String()), int64((&Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines) | ||||||
|  | 		commitID = commit.ID.String() | ||||||
|  | 	} | ||||||
|  | 	return CreateComment(&CreateCommentOptions{ | ||||||
|  | 		Type:      CommentTypeCode, | ||||||
|  | 		Doer:      doer, | ||||||
|  | 		Repo:      repo, | ||||||
|  | 		Issue:     issue, | ||||||
|  | 		Content:   content, | ||||||
|  | 		LineNum:   line, | ||||||
|  | 		TreePath:  treePath, | ||||||
|  | 		CommitSHA: commitID, | ||||||
|  | 		ReviewID:  reviewID, | ||||||
|  | 		Patch:     patch, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // CreateRefComment creates a commit reference comment to issue.
 | // CreateRefComment creates a commit reference comment to issue.
 | ||||||
| func CreateRefComment(doer *User, repo *Repository, issue *Issue, content, commitSHA string) error { | func CreateRefComment(doer *User, repo *Repository, issue *Issue, content, commitSHA string) error { | ||||||
| 	if len(commitSHA) == 0 { | 	if len(commitSHA) == 0 { | ||||||
|  | @ -716,10 +905,11 @@ func GetCommentByID(id int64) (*Comment, error) { | ||||||
| 
 | 
 | ||||||
| // FindCommentsOptions describes the conditions to Find comments
 | // FindCommentsOptions describes the conditions to Find comments
 | ||||||
| type FindCommentsOptions struct { | type FindCommentsOptions struct { | ||||||
| 	RepoID  int64 | 	RepoID   int64 | ||||||
| 	IssueID int64 | 	IssueID  int64 | ||||||
| 	Since   int64 | 	ReviewID int64 | ||||||
| 	Type    CommentType | 	Since    int64 | ||||||
|  | 	Type     CommentType | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (opts *FindCommentsOptions) toConds() builder.Cond { | func (opts *FindCommentsOptions) toConds() builder.Cond { | ||||||
|  | @ -730,6 +920,9 @@ func (opts *FindCommentsOptions) toConds() builder.Cond { | ||||||
| 	if opts.IssueID > 0 { | 	if opts.IssueID > 0 { | ||||||
| 		cond = cond.And(builder.Eq{"comment.issue_id": opts.IssueID}) | 		cond = cond.And(builder.Eq{"comment.issue_id": opts.IssueID}) | ||||||
| 	} | 	} | ||||||
|  | 	if opts.ReviewID > 0 { | ||||||
|  | 		cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID}) | ||||||
|  | 	} | ||||||
| 	if opts.Since > 0 { | 	if opts.Since > 0 { | ||||||
| 		cond = cond.And(builder.Gte{"comment.updated_unix": opts.Since}) | 		cond = cond.And(builder.Gte{"comment.updated_unix": opts.Since}) | ||||||
| 	} | 	} | ||||||
|  | @ -870,3 +1063,75 @@ func DeleteComment(doer *User, comment *Comment) error { | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // CodeComments represents comments on code by using this structure: FILENAME -> LINE (+ == proposed; - == previous) -> COMMENTS
 | ||||||
|  | type CodeComments map[string]map[int64][]*Comment | ||||||
|  | 
 | ||||||
|  | func fetchCodeComments(e Engine, issue *Issue, currentUser *User) (CodeComments, error) { | ||||||
|  | 	return fetchCodeCommentsByReview(e, issue, currentUser, nil) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func fetchCodeCommentsByReview(e Engine, issue *Issue, currentUser *User, review *Review) (CodeComments, error) { | ||||||
|  | 	pathToLineToComment := make(CodeComments) | ||||||
|  | 	if review == nil { | ||||||
|  | 		review = &Review{ID: 0} | ||||||
|  | 	} | ||||||
|  | 	//Find comments
 | ||||||
|  | 	opts := FindCommentsOptions{ | ||||||
|  | 		Type:     CommentTypeCode, | ||||||
|  | 		IssueID:  issue.ID, | ||||||
|  | 		ReviewID: review.ID, | ||||||
|  | 	} | ||||||
|  | 	conds := opts.toConds() | ||||||
|  | 	if review.ID == 0 { | ||||||
|  | 		conds.And(builder.Eq{"invalidated": false}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var comments []*Comment | ||||||
|  | 	if err := e.Where(conds). | ||||||
|  | 		Asc("comment.created_unix"). | ||||||
|  | 		Asc("comment.id"). | ||||||
|  | 		Find(&comments); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := issue.loadRepo(e); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	// Find all reviews by ReviewID
 | ||||||
|  | 	reviews := make(map[int64]*Review) | ||||||
|  | 	var ids = make([]int64, 0, len(comments)) | ||||||
|  | 	for _, comment := range comments { | ||||||
|  | 		if comment.ReviewID != 0 { | ||||||
|  | 			ids = append(ids, comment.ReviewID) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if err := e.In("id", ids).Find(&reviews); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	for _, comment := range comments { | ||||||
|  | 		if re, ok := reviews[comment.ReviewID]; ok && re != nil { | ||||||
|  | 			// If the review is pending only the author can see the comments (except the review is set)
 | ||||||
|  | 			if review.ID == 0 { | ||||||
|  | 				if re.Type == ReviewTypePending && | ||||||
|  | 					(currentUser == nil || currentUser.ID != re.ReviewerID) { | ||||||
|  | 					continue | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			comment.Review = re | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		comment.RenderedContent = string(markdown.Render([]byte(comment.Content), issue.Repo.Link(), | ||||||
|  | 			issue.Repo.ComposeMetas())) | ||||||
|  | 		if pathToLineToComment[comment.TreePath] == nil { | ||||||
|  | 			pathToLineToComment[comment.TreePath] = make(map[int64][]*Comment) | ||||||
|  | 		} | ||||||
|  | 		pathToLineToComment[comment.TreePath][comment.Line] = append(pathToLineToComment[comment.TreePath][comment.Line], comment) | ||||||
|  | 	} | ||||||
|  | 	return pathToLineToComment, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // FetchCodeComments will return a 2d-map: ["Path"]["Line"] = Comments at line
 | ||||||
|  | func FetchCodeComments(issue *Issue, currentUser *User) (CodeComments, error) { | ||||||
|  | 	return fetchCodeComments(x, issue, currentUser) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -39,3 +39,21 @@ func TestCreateComment(t *testing.T) { | ||||||
| 	updatedIssue := AssertExistsAndLoadBean(t, &Issue{ID: issue.ID}).(*Issue) | 	updatedIssue := AssertExistsAndLoadBean(t, &Issue{ID: issue.ID}).(*Issue) | ||||||
| 	AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix)) | 	AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix)) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func TestFetchCodeComments(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 
 | ||||||
|  | 	issue := AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue) | ||||||
|  | 	user := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User) | ||||||
|  | 	res, err := FetchCodeComments(issue, user) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  | 	assert.Contains(t, res, "README.md") | ||||||
|  | 	assert.Contains(t, res["README.md"], int64(4)) | ||||||
|  | 	assert.Len(t, res["README.md"][4], 1) | ||||||
|  | 	assert.Equal(t, int64(4), res["README.md"][4][0].ID) | ||||||
|  | 
 | ||||||
|  | 	user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) | ||||||
|  | 	res, err = FetchCodeComments(issue, user2) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  | 	assert.Len(t, res, 1) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -194,8 +194,10 @@ var migrations = []Migration{ | ||||||
| 	NewMigration("move team units to team_unit table", moveTeamUnitsToTeamUnitTable), | 	NewMigration("move team units to team_unit table", moveTeamUnitsToTeamUnitTable), | ||||||
| 	// v70 -> v71
 | 	// v70 -> v71
 | ||||||
| 	NewMigration("add issue_dependencies", addIssueDependencies), | 	NewMigration("add issue_dependencies", addIssueDependencies), | ||||||
| 	// v70 -> v71
 | 	// v71 -> v72
 | ||||||
| 	NewMigration("protect each scratch token", addScratchHash), | 	NewMigration("protect each scratch token", addScratchHash), | ||||||
|  | 	// v72 -> v73
 | ||||||
|  | 	NewMigration("add review", addReview), | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Migrate database to current version
 | // Migrate database to current version
 | ||||||
|  |  | ||||||
							
								
								
									
										31
									
								
								models/migrations/v72.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								models/migrations/v72.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | ||||||
|  | // Copyright 2018 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package migrations | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 
 | ||||||
|  | 	"code.gitea.io/gitea/modules/util" | ||||||
|  | 
 | ||||||
|  | 	"github.com/go-xorm/xorm" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func addReview(x *xorm.Engine) error { | ||||||
|  | 	// Review see models/review.go
 | ||||||
|  | 	type Review struct { | ||||||
|  | 		ID          int64 `xorm:"pk autoincr"` | ||||||
|  | 		Type        string | ||||||
|  | 		ReviewerID  int64 `xorm:"index"` | ||||||
|  | 		IssueID     int64 `xorm:"index"` | ||||||
|  | 		Content     string | ||||||
|  | 		CreatedUnix util.TimeStamp `xorm:"INDEX created"` | ||||||
|  | 		UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := x.Sync2(new(Review)); err != nil { | ||||||
|  | 		return fmt.Errorf("Sync2: %v", err) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | @ -124,6 +124,7 @@ func init() { | ||||||
| 		new(IssueAssignees), | 		new(IssueAssignees), | ||||||
| 		new(U2FRegistration), | 		new(U2FRegistration), | ||||||
| 		new(TeamUnit), | 		new(TeamUnit), | ||||||
|  | 		new(Review), | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	gonicNames := []string{"SSL", "UID"} | 	gonicNames := []string{"SSL", "UID"} | ||||||
|  |  | ||||||
|  | @ -1075,10 +1075,7 @@ func (prs PullRequestList) loadAttributes(e Engine) error { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Load issues.
 | 	// Load issues.
 | ||||||
| 	issueIDs := make([]int64, 0, len(prs)) | 	issueIDs := prs.getIssueIDs() | ||||||
| 	for i := range prs { |  | ||||||
| 		issueIDs = append(issueIDs, prs[i].IssueID) |  | ||||||
| 	} |  | ||||||
| 	issues := make([]*Issue, 0, len(issueIDs)) | 	issues := make([]*Issue, 0, len(issueIDs)) | ||||||
| 	if err := e. | 	if err := e. | ||||||
| 		Where("id > 0"). | 		Where("id > 0"). | ||||||
|  | @ -1097,11 +1094,44 @@ func (prs PullRequestList) loadAttributes(e Engine) error { | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (prs PullRequestList) getIssueIDs() []int64 { | ||||||
|  | 	issueIDs := make([]int64, 0, len(prs)) | ||||||
|  | 	for i := range prs { | ||||||
|  | 		issueIDs = append(issueIDs, prs[i].IssueID) | ||||||
|  | 	} | ||||||
|  | 	return issueIDs | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // LoadAttributes load all the prs attributes
 | // LoadAttributes load all the prs attributes
 | ||||||
| func (prs PullRequestList) LoadAttributes() error { | func (prs PullRequestList) LoadAttributes() error { | ||||||
| 	return prs.loadAttributes(x) | 	return prs.loadAttributes(x) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (prs PullRequestList) invalidateCodeComments(e Engine, doer *User, repo *git.Repository, branch string) error { | ||||||
|  | 	if len(prs) == 0 { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 	issueIDs := prs.getIssueIDs() | ||||||
|  | 	var codeComments []*Comment | ||||||
|  | 	if err := e. | ||||||
|  | 		Where("type = ? and invalidated = ?", CommentTypeCode, false). | ||||||
|  | 		In("issue_id", issueIDs). | ||||||
|  | 		Find(&codeComments); err != nil { | ||||||
|  | 		return fmt.Errorf("find code comments: %v", err) | ||||||
|  | 	} | ||||||
|  | 	for _, comment := range codeComments { | ||||||
|  | 		if err := comment.CheckInvalidation(repo, doer, branch); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // InvalidateCodeComments will lookup the prs for code comments which got invalidated by change
 | ||||||
|  | func (prs PullRequestList) InvalidateCodeComments(doer *User, repo *git.Repository, branch string) error { | ||||||
|  | 	return prs.invalidateCodeComments(x, doer, repo, branch) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func addHeadRepoTasks(prs []*PullRequest) { | func addHeadRepoTasks(prs []*PullRequest) { | ||||||
| 	for _, pr := range prs { | 	for _, pr := range prs { | ||||||
| 		log.Trace("addHeadRepoTasks[%d]: composing new test task", pr.ID) | 		log.Trace("addHeadRepoTasks[%d]: composing new test task", pr.ID) | ||||||
|  | @ -1128,10 +1158,13 @@ func AddTestPullRequestTask(doer *User, repoID int64, branch string, isSync bool | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if isSync { | 	if isSync { | ||||||
| 		if err = PullRequestList(prs).LoadAttributes(); err != nil { | 		requests := PullRequestList(prs) | ||||||
|  | 		if err = requests.LoadAttributes(); err != nil { | ||||||
| 			log.Error(4, "PullRequestList.LoadAttributes: %v", err) | 			log.Error(4, "PullRequestList.LoadAttributes: %v", err) | ||||||
| 		} | 		} | ||||||
| 
 | 		if invalidationErr := checkForInvalidation(requests, repoID, doer, branch); invalidationErr != nil { | ||||||
|  | 			log.Error(4, "checkForInvalidation: %v", invalidationErr) | ||||||
|  | 		} | ||||||
| 		if err == nil { | 		if err == nil { | ||||||
| 			for _, pr := range prs { | 			for _, pr := range prs { | ||||||
| 				pr.Issue.PullRequest = pr | 				pr.Issue.PullRequest = pr | ||||||
|  | @ -1152,6 +1185,7 @@ func AddTestPullRequestTask(doer *User, repoID int64, branch string, isSync bool | ||||||
| 				go HookQueue.Add(pr.Issue.Repo.ID) | 				go HookQueue.Add(pr.Issue.Repo.ID) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	addHeadRepoTasks(prs) | 	addHeadRepoTasks(prs) | ||||||
|  | @ -1167,6 +1201,24 @@ func AddTestPullRequestTask(doer *User, repoID int64, branch string, isSync bool | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func checkForInvalidation(requests PullRequestList, repoID int64, doer *User, branch string) error { | ||||||
|  | 	repo, err := GetRepositoryByID(repoID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("GetRepositoryByID: %v", err) | ||||||
|  | 	} | ||||||
|  | 	gitRepo, err := git.OpenRepository(repo.RepoPath()) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("git.OpenRepository: %v", err) | ||||||
|  | 	} | ||||||
|  | 	go func() { | ||||||
|  | 		err := requests.InvalidateCodeComments(doer, gitRepo, branch) | ||||||
|  | 		if err != nil { | ||||||
|  | 			log.Error(4, "PullRequestList.InvalidateCodeComments: %v", err) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // ChangeUsernameInPullRequests changes the name of head_user_name
 | // ChangeUsernameInPullRequests changes the name of head_user_name
 | ||||||
| func ChangeUsernameInPullRequests(oldUserName, newUserName string) error { | func ChangeUsernameInPullRequests(oldUserName, newUserName string) error { | ||||||
| 	pr := PullRequest{ | 	pr := PullRequest{ | ||||||
|  |  | ||||||
							
								
								
									
										256
									
								
								models/review.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								models/review.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,256 @@ | ||||||
|  | // Copyright 2018 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package models | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 
 | ||||||
|  | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | 	"code.gitea.io/gitea/modules/util" | ||||||
|  | 	"github.com/go-xorm/xorm" | ||||||
|  | 
 | ||||||
|  | 	"github.com/go-xorm/builder" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // ReviewType defines the sort of feedback a review gives
 | ||||||
|  | type ReviewType int | ||||||
|  | 
 | ||||||
|  | // ReviewTypeUnknown unknown review type
 | ||||||
|  | const ReviewTypeUnknown ReviewType = -1 | ||||||
|  | 
 | ||||||
|  | const ( | ||||||
|  | 	// ReviewTypePending is a review which is not published yet
 | ||||||
|  | 	ReviewTypePending ReviewType = iota | ||||||
|  | 	// ReviewTypeApprove approves changes
 | ||||||
|  | 	ReviewTypeApprove | ||||||
|  | 	// ReviewTypeComment gives general feedback
 | ||||||
|  | 	ReviewTypeComment | ||||||
|  | 	// ReviewTypeReject gives feedback blocking merge
 | ||||||
|  | 	ReviewTypeReject | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Icon returns the corresponding icon for the review type
 | ||||||
|  | func (rt ReviewType) Icon() string { | ||||||
|  | 	switch rt { | ||||||
|  | 	case ReviewTypeApprove: | ||||||
|  | 		return "eye" | ||||||
|  | 	case ReviewTypeReject: | ||||||
|  | 		return "x" | ||||||
|  | 	default: | ||||||
|  | 	case ReviewTypeComment: | ||||||
|  | 	case ReviewTypeUnknown: | ||||||
|  | 		return "comment" | ||||||
|  | 	} | ||||||
|  | 	return "comment" | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Review represents collection of code comments giving feedback for a PR
 | ||||||
|  | type Review struct { | ||||||
|  | 	ID         int64 `xorm:"pk autoincr"` | ||||||
|  | 	Type       ReviewType | ||||||
|  | 	Reviewer   *User  `xorm:"-"` | ||||||
|  | 	ReviewerID int64  `xorm:"index"` | ||||||
|  | 	Issue      *Issue `xorm:"-"` | ||||||
|  | 	IssueID    int64  `xorm:"index"` | ||||||
|  | 	Content    string | ||||||
|  | 
 | ||||||
|  | 	CreatedUnix util.TimeStamp `xorm:"INDEX created"` | ||||||
|  | 	UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` | ||||||
|  | 
 | ||||||
|  | 	// CodeComments are the initial code comments of the review
 | ||||||
|  | 	CodeComments CodeComments `xorm:"-"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *Review) loadCodeComments(e Engine) (err error) { | ||||||
|  | 	r.CodeComments, err = fetchCodeCommentsByReview(e, r.Issue, nil, r) | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // LoadCodeComments loads CodeComments
 | ||||||
|  | func (r *Review) LoadCodeComments() error { | ||||||
|  | 	return r.loadCodeComments(x) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *Review) loadIssue(e Engine) (err error) { | ||||||
|  | 	r.Issue, err = getIssueByID(e, r.IssueID) | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *Review) loadReviewer(e Engine) (err error) { | ||||||
|  | 	if r.ReviewerID == 0 { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 	r.Reviewer, err = getUserByID(e, r.ReviewerID) | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *Review) loadAttributes(e Engine) (err error) { | ||||||
|  | 	if err = r.loadReviewer(e); err != nil { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if err = r.loadIssue(e); err != nil { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // LoadAttributes loads all attributes except CodeComments
 | ||||||
|  | func (r *Review) LoadAttributes() error { | ||||||
|  | 	return r.loadAttributes(x) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Publish will send notifications / actions to participants for all code comments; parts are concurrent
 | ||||||
|  | func (r *Review) Publish() error { | ||||||
|  | 	return r.publish(x) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *Review) publish(e *xorm.Engine) error { | ||||||
|  | 	if r.Type == ReviewTypePending || r.Type == ReviewTypeUnknown { | ||||||
|  | 		return fmt.Errorf("review cannot be published if type is pending or unknown") | ||||||
|  | 	} | ||||||
|  | 	if r.Issue == nil { | ||||||
|  | 		if err := r.loadIssue(e); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if err := r.Issue.loadRepo(e); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if len(r.CodeComments) == 0 { | ||||||
|  | 		if err := r.loadCodeComments(e); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	for _, lines := range r.CodeComments { | ||||||
|  | 		for _, comments := range lines { | ||||||
|  | 			for _, comment := range comments { | ||||||
|  | 				go func(en *xorm.Engine, review *Review, comm *Comment) { | ||||||
|  | 					sess := en.NewSession() | ||||||
|  | 					defer sess.Close() | ||||||
|  | 					if err := sendCreateCommentAction(sess, &CreateCommentOptions{ | ||||||
|  | 						Doer:    comm.Poster, | ||||||
|  | 						Issue:   review.Issue, | ||||||
|  | 						Repo:    review.Issue.Repo, | ||||||
|  | 						Type:    comm.Type, | ||||||
|  | 						Content: comm.Content, | ||||||
|  | 					}, comm); err != nil { | ||||||
|  | 						log.Warn("sendCreateCommentAction: %v", err) | ||||||
|  | 					} | ||||||
|  | 				}(e, r, comment) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func getReviewByID(e Engine, id int64) (*Review, error) { | ||||||
|  | 	review := new(Review) | ||||||
|  | 	if has, err := e.ID(id).Get(review); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} else if !has { | ||||||
|  | 		return nil, ErrReviewNotExist{ID: id} | ||||||
|  | 	} else { | ||||||
|  | 		return review, nil | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetReviewByID returns the review by the given ID
 | ||||||
|  | func GetReviewByID(id int64) (*Review, error) { | ||||||
|  | 	return getReviewByID(x, id) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // FindReviewOptions represent possible filters to find reviews
 | ||||||
|  | type FindReviewOptions struct { | ||||||
|  | 	Type       ReviewType | ||||||
|  | 	IssueID    int64 | ||||||
|  | 	ReviewerID int64 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (opts *FindReviewOptions) toCond() builder.Cond { | ||||||
|  | 	var cond = builder.NewCond() | ||||||
|  | 	if opts.IssueID > 0 { | ||||||
|  | 		cond = cond.And(builder.Eq{"issue_id": opts.IssueID}) | ||||||
|  | 	} | ||||||
|  | 	if opts.ReviewerID > 0 { | ||||||
|  | 		cond = cond.And(builder.Eq{"reviewer_id": opts.ReviewerID}) | ||||||
|  | 	} | ||||||
|  | 	if opts.Type != ReviewTypeUnknown { | ||||||
|  | 		cond = cond.And(builder.Eq{"type": opts.Type}) | ||||||
|  | 	} | ||||||
|  | 	return cond | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func findReviews(e Engine, opts FindReviewOptions) ([]*Review, error) { | ||||||
|  | 	reviews := make([]*Review, 0, 10) | ||||||
|  | 	sess := e.Where(opts.toCond()) | ||||||
|  | 	return reviews, sess. | ||||||
|  | 		Asc("created_unix"). | ||||||
|  | 		Asc("id"). | ||||||
|  | 		Find(&reviews) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // FindReviews returns reviews passing FindReviewOptions
 | ||||||
|  | func FindReviews(opts FindReviewOptions) ([]*Review, error) { | ||||||
|  | 	return findReviews(x, opts) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CreateReviewOptions represent the options to create a review. Type, Issue and Reviewer are required.
 | ||||||
|  | type CreateReviewOptions struct { | ||||||
|  | 	Content  string | ||||||
|  | 	Type     ReviewType | ||||||
|  | 	Issue    *Issue | ||||||
|  | 	Reviewer *User | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func createReview(e Engine, opts CreateReviewOptions) (*Review, error) { | ||||||
|  | 	review := &Review{ | ||||||
|  | 		Type:       opts.Type, | ||||||
|  | 		Issue:      opts.Issue, | ||||||
|  | 		IssueID:    opts.Issue.ID, | ||||||
|  | 		Reviewer:   opts.Reviewer, | ||||||
|  | 		ReviewerID: opts.Reviewer.ID, | ||||||
|  | 		Content:    opts.Content, | ||||||
|  | 	} | ||||||
|  | 	if _, err := e.Insert(review); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return review, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CreateReview creates a new review based on opts
 | ||||||
|  | func CreateReview(opts CreateReviewOptions) (*Review, error) { | ||||||
|  | 	return createReview(x, opts) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func getCurrentReview(e Engine, reviewer *User, issue *Issue) (*Review, error) { | ||||||
|  | 	if reviewer == nil { | ||||||
|  | 		return nil, nil | ||||||
|  | 	} | ||||||
|  | 	reviews, err := findReviews(e, FindReviewOptions{ | ||||||
|  | 		Type:       ReviewTypePending, | ||||||
|  | 		IssueID:    issue.ID, | ||||||
|  | 		ReviewerID: reviewer.ID, | ||||||
|  | 	}) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	if len(reviews) == 0 { | ||||||
|  | 		return nil, ErrReviewNotExist{} | ||||||
|  | 	} | ||||||
|  | 	return reviews[0], nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetCurrentReview returns the current pending review of reviewer for given issue
 | ||||||
|  | func GetCurrentReview(reviewer *User, issue *Issue) (*Review, error) { | ||||||
|  | 	return getCurrentReview(x, reviewer, issue) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // UpdateReview will update all cols of the given review in db
 | ||||||
|  | func UpdateReview(r *Review) error { | ||||||
|  | 	if _, err := x.ID(r.ID).AllCols().Update(r); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
							
								
								
									
										107
									
								
								models/review_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								models/review_test.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,107 @@ | ||||||
|  | package models | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestGetReviewByID(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	review, err := GetReviewByID(1) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  | 	assert.Equal(t, "Demo Review", review.Content) | ||||||
|  | 	assert.Equal(t, ReviewTypeApprove, review.Type) | ||||||
|  | 
 | ||||||
|  | 	_, err = GetReviewByID(23892) | ||||||
|  | 	assert.Error(t, err) | ||||||
|  | 	assert.True(t, IsErrReviewNotExist(err), "IsErrReviewNotExist") | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestReview_LoadAttributes(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	review := AssertExistsAndLoadBean(t, &Review{ID: 1}).(*Review) | ||||||
|  | 	assert.NoError(t, review.LoadAttributes()) | ||||||
|  | 	assert.NotNil(t, review.Issue) | ||||||
|  | 	assert.NotNil(t, review.Reviewer) | ||||||
|  | 
 | ||||||
|  | 	invalidReview1 := AssertExistsAndLoadBean(t, &Review{ID: 2}).(*Review) | ||||||
|  | 	assert.Error(t, invalidReview1.LoadAttributes()) | ||||||
|  | 
 | ||||||
|  | 	invalidReview2 := AssertExistsAndLoadBean(t, &Review{ID: 3}).(*Review) | ||||||
|  | 	assert.Error(t, invalidReview2.LoadAttributes()) | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestReview_LoadCodeComments(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 
 | ||||||
|  | 	review := AssertExistsAndLoadBean(t, &Review{ID: 4}).(*Review) | ||||||
|  | 	assert.NoError(t, review.LoadAttributes()) | ||||||
|  | 	assert.NoError(t, review.LoadCodeComments()) | ||||||
|  | 	assert.Len(t, review.CodeComments, 1) | ||||||
|  | 	assert.Equal(t, int64(4), review.CodeComments["README.md"][int64(4)][0].Line) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestReviewType_Icon(t *testing.T) { | ||||||
|  | 	assert.Equal(t, "eye", ReviewTypeApprove.Icon()) | ||||||
|  | 	assert.Equal(t, "x", ReviewTypeReject.Icon()) | ||||||
|  | 	assert.Equal(t, "comment", ReviewTypeComment.Icon()) | ||||||
|  | 	assert.Equal(t, "comment", ReviewTypeUnknown.Icon()) | ||||||
|  | 	assert.Equal(t, "comment", ReviewType(4).Icon()) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestFindReviews(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	reviews, err := FindReviews(FindReviewOptions{ | ||||||
|  | 		Type:       ReviewTypeApprove, | ||||||
|  | 		IssueID:    2, | ||||||
|  | 		ReviewerID: 1, | ||||||
|  | 	}) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  | 	assert.Len(t, reviews, 1) | ||||||
|  | 	assert.Equal(t, "Demo Review", reviews[0].Content) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestGetCurrentReview(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	issue := AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue) | ||||||
|  | 	user := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User) | ||||||
|  | 
 | ||||||
|  | 	review, err := GetCurrentReview(user, issue) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  | 	assert.NotNil(t, review) | ||||||
|  | 	assert.Equal(t, ReviewTypePending, review.Type) | ||||||
|  | 	assert.Equal(t, "Pending Review", review.Content) | ||||||
|  | 
 | ||||||
|  | 	user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) | ||||||
|  | 	review2, err := GetCurrentReview(user2, issue) | ||||||
|  | 	assert.Error(t, err) | ||||||
|  | 	assert.True(t, IsErrReviewNotExist(err)) | ||||||
|  | 	assert.Nil(t, review2) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestCreateReview(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 
 | ||||||
|  | 	issue := AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue) | ||||||
|  | 	user := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User) | ||||||
|  | 
 | ||||||
|  | 	review, err := CreateReview(CreateReviewOptions{ | ||||||
|  | 		Content:  "New Review", | ||||||
|  | 		Type:     ReviewTypePending, | ||||||
|  | 		Issue:    issue, | ||||||
|  | 		Reviewer: user, | ||||||
|  | 	}) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  | 	assert.Equal(t, "New Review", review.Content) | ||||||
|  | 	AssertExistsAndLoadBean(t, &Review{Content: "New Review"}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestUpdateReview(t *testing.T) { | ||||||
|  | 	assert.NoError(t, PrepareTestDatabase()) | ||||||
|  | 	review := AssertExistsAndLoadBean(t, &Review{ID: 1}).(*Review) | ||||||
|  | 	review.Content = "Updated Review" | ||||||
|  | 	assert.NoError(t, UpdateReview(review)) | ||||||
|  | 	AssertExistsAndLoadBean(t, &Review{ID: 1, Content: "Updated Review"}) | ||||||
|  | } | ||||||
|  | @ -363,6 +363,45 @@ func (f *MergePullRequestForm) Validate(ctx *macaron.Context, errs binding.Error | ||||||
| 	return validate(errs, ctx.Data, f, ctx.Locale) | 	return validate(errs, ctx.Data, f, ctx.Locale) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // CodeCommentForm form for adding code comments for PRs
 | ||||||
|  | type CodeCommentForm struct { | ||||||
|  | 	Content  string `binding:"Required"` | ||||||
|  | 	Side     string `binding:"Required;In(previous,proposed)"` | ||||||
|  | 	Line     int64 | ||||||
|  | 	TreePath string `form:"path" binding:"Required"` | ||||||
|  | 	IsReview bool   `form:"is_review"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Validate validates the fields
 | ||||||
|  | func (f *CodeCommentForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { | ||||||
|  | 	return validate(errs, ctx.Data, f, ctx.Locale) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SubmitReviewForm for submitting a finished code review
 | ||||||
|  | type SubmitReviewForm struct { | ||||||
|  | 	Content string | ||||||
|  | 	Type    string `binding:"Required;In(approve,comment,reject)"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Validate validates the fields
 | ||||||
|  | func (f *SubmitReviewForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { | ||||||
|  | 	return validate(errs, ctx.Data, f, ctx.Locale) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // ReviewType will return the corresponding reviewtype for type
 | ||||||
|  | func (f SubmitReviewForm) ReviewType() models.ReviewType { | ||||||
|  | 	switch f.Type { | ||||||
|  | 	case "approve": | ||||||
|  | 		return models.ReviewTypeApprove | ||||||
|  | 	case "comment": | ||||||
|  | 		return models.ReviewTypeComment | ||||||
|  | 	case "reject": | ||||||
|  | 		return models.ReviewTypeReject | ||||||
|  | 	default: | ||||||
|  | 		return models.ReviewTypeUnknown | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // __________       .__
 | // __________       .__
 | ||||||
| // \______   \ ____ |  |   ____ _____    ______ ____
 | // \______   \ ____ |  |   ____ _____    ______ ____
 | ||||||
| //  |       _// __ \|  | _/ __ \\__  \  /  ___// __ \
 | //  |       _// __ \|  | _/ __ \\__  \  /  ___// __ \
 | ||||||
|  |  | ||||||
|  | @ -277,6 +277,7 @@ var ( | ||||||
| 		RepoSearchPagingNum int | 		RepoSearchPagingNum int | ||||||
| 		FeedMaxCommitNum    int | 		FeedMaxCommitNum    int | ||||||
| 		GraphMaxCommitNum   int | 		GraphMaxCommitNum   int | ||||||
|  | 		CodeCommentLines    int | ||||||
| 		ReactionMaxUserNum  int | 		ReactionMaxUserNum  int | ||||||
| 		ThemeColorMetaTag   string | 		ThemeColorMetaTag   string | ||||||
| 		MaxDisplayFileSize  int64 | 		MaxDisplayFileSize  int64 | ||||||
|  | @ -303,6 +304,7 @@ var ( | ||||||
| 		RepoSearchPagingNum: 10, | 		RepoSearchPagingNum: 10, | ||||||
| 		FeedMaxCommitNum:    5, | 		FeedMaxCommitNum:    5, | ||||||
| 		GraphMaxCommitNum:   100, | 		GraphMaxCommitNum:   100, | ||||||
|  | 		CodeCommentLines:    4, | ||||||
| 		ReactionMaxUserNum:  10, | 		ReactionMaxUserNum:  10, | ||||||
| 		ThemeColorMetaTag:   `#6cc644`, | 		ThemeColorMetaTag:   `#6cc644`, | ||||||
| 		MaxDisplayFileSize:  8388608, | 		MaxDisplayFileSize:  8388608, | ||||||
|  |  | ||||||
|  | @ -189,6 +189,32 @@ func NewFuncMap() []template.FuncMap { | ||||||
| 		"DefaultTheme": func() string { | 		"DefaultTheme": func() string { | ||||||
| 			return setting.UI.DefaultTheme | 			return setting.UI.DefaultTheme | ||||||
| 		}, | 		}, | ||||||
|  | 		"dict": func(values ...interface{}) (map[string]interface{}, error) { | ||||||
|  | 			if len(values) == 0 { | ||||||
|  | 				return nil, errors.New("invalid dict call") | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			dict := make(map[string]interface{}) | ||||||
|  | 
 | ||||||
|  | 			for i := 0; i < len(values); i++ { | ||||||
|  | 				switch key := values[i].(type) { | ||||||
|  | 				case string: | ||||||
|  | 					i++ | ||||||
|  | 					if i == len(values) { | ||||||
|  | 						return nil, errors.New("specify the key for non array values") | ||||||
|  | 					} | ||||||
|  | 					dict[key] = values[i] | ||||||
|  | 				case map[string]interface{}: | ||||||
|  | 					m := values[i].(map[string]interface{}) | ||||||
|  | 					for i, v := range m { | ||||||
|  | 						dict[i] = v | ||||||
|  | 					} | ||||||
|  | 				default: | ||||||
|  | 					return nil, errors.New("dict values must be maps") | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			return dict, nil | ||||||
|  | 		}, | ||||||
| 	}} | 	}} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -72,6 +72,10 @@ issues = Issues | ||||||
| 
 | 
 | ||||||
| cancel = Cancel | cancel = Cancel | ||||||
| 
 | 
 | ||||||
|  | write = Write | ||||||
|  | preview = Preview | ||||||
|  | loading = Loading… | ||||||
|  | 
 | ||||||
| [install] | [install] | ||||||
| install = Installation | install = Installation | ||||||
| title = Initial Configuration | title = Initial Configuration | ||||||
|  | @ -809,6 +813,13 @@ issues.dependency.add_error_dep_not_exist = Dependency does not exist. | ||||||
| issues.dependency.add_error_dep_exists = Dependency already exists. | issues.dependency.add_error_dep_exists = Dependency already exists. | ||||||
| issues.dependency.add_error_cannot_create_circular = You cannot create a dependency with two issues blocking each other. | issues.dependency.add_error_cannot_create_circular = You cannot create a dependency with two issues blocking each other. | ||||||
| issues.dependency.add_error_dep_not_same_repo = Both issues must be in the same repository. | issues.dependency.add_error_dep_not_same_repo = Both issues must be in the same repository. | ||||||
|  | issues.review.approve = "approved these changes %s" | ||||||
|  | issues.review.comment = "reviewed %s" | ||||||
|  | issues.review.reject = "rejected these changes %s" | ||||||
|  | issues.review.pending = Pending | ||||||
|  | issues.review.review = Review | ||||||
|  | issues.review.show_outdated = Show outdated | ||||||
|  | issues.review.hide_outdated = Hide outdated | ||||||
| 
 | 
 | ||||||
| pulls.desc = Enable merge requests and code reviews. | pulls.desc = Enable merge requests and code reviews. | ||||||
| pulls.new = New Pull Request | pulls.new = New Pull Request | ||||||
|  | @ -1139,6 +1150,18 @@ diff.bin = BIN | ||||||
| diff.view_file = View File | diff.view_file = View File | ||||||
| diff.file_suppressed = File diff suppressed because it is too large | diff.file_suppressed = File diff suppressed because it is too large | ||||||
| diff.too_many_files = Some files were not shown because too many files changed in this diff | diff.too_many_files = Some files were not shown because too many files changed in this diff | ||||||
|  | diff.comment.placeholder = Leave a comment | ||||||
|  | diff.comment.markdown_info = Styling with markdown is supported. | ||||||
|  | diff.comment.add_single_comment = Add single comment | ||||||
|  | diff.comment.add_review_comment = Add comment | ||||||
|  | diff.comment.start_review = Start review | ||||||
|  | diff.comment.reply = Reply | ||||||
|  | diff.review = Review | ||||||
|  | diff.review.header = Submit review | ||||||
|  | diff.review.placeholder = Review comment | ||||||
|  | diff.review.comment = Comment | ||||||
|  | diff.review.approve = Approve | ||||||
|  | diff.review.reject = Reject | ||||||
| 
 | 
 | ||||||
| releases.desc = Track project versions and downloads. | releases.desc = Track project versions and downloads. | ||||||
| release.releases = Releases | release.releases = Releases | ||||||
|  | @ -1156,9 +1179,6 @@ release.target = Target | ||||||
| release.tag_helper = Choose an existing tag or create a new tag. | release.tag_helper = Choose an existing tag or create a new tag. | ||||||
| release.title = Title | release.title = Title | ||||||
| release.content = Content | release.content = Content | ||||||
| release.write = Write |  | ||||||
| release.preview = Preview |  | ||||||
| release.loading = Loading… |  | ||||||
| release.prerelease_desc = Mark as Pre-Release | release.prerelease_desc = Mark as Pre-Release | ||||||
| release.prerelease_helper = Mark this release unsuitable for production use. | release.prerelease_helper = Mark this release unsuitable for production use. | ||||||
| release.cancel = Cancel | release.cancel = Cancel | ||||||
|  |  | ||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -604,7 +604,7 @@ function initRepository() { | ||||||
|             // Setup new form
 |             // Setup new form
 | ||||||
|             if ($editContentZone.html().length == 0) { |             if ($editContentZone.html().length == 0) { | ||||||
|                 $editContentZone.html($('#edit-content-form').html()); |                 $editContentZone.html($('#edit-content-form').html()); | ||||||
|                 $textarea = $segment.find('textarea'); |                 $textarea = $('#content'); | ||||||
|                 issuesTribute.attach($textarea.get()); |                 issuesTribute.attach($textarea.get()); | ||||||
|                 emojiTribute.attach($textarea.get()); |                 emojiTribute.attach($textarea.get()); | ||||||
| 
 | 
 | ||||||
|  | @ -761,6 +761,104 @@ function initRepository() { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | function initPullRequestReview() { | ||||||
|  |     $('.show-outdated').on('click', function (e) { | ||||||
|  |         e.preventDefault(); | ||||||
|  |         var id = $(this).data('comment'); | ||||||
|  |         $(this).addClass("hide"); | ||||||
|  |         $("#code-comments-" + id).removeClass('hide'); | ||||||
|  |         $("#code-preview-" + id).removeClass('hide'); | ||||||
|  |         $("#hide-outdated-" + id).removeClass('hide'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     $('.hide-outdated').on('click', function (e) { | ||||||
|  |         e.preventDefault(); | ||||||
|  |         var id = $(this).data('comment'); | ||||||
|  |         $(this).addClass("hide"); | ||||||
|  |         $("#code-comments-" + id).addClass('hide'); | ||||||
|  |         $("#code-preview-" + id).addClass('hide'); | ||||||
|  |         $("#show-outdated-" + id).removeClass('hide'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     $('.comment-form-reply').on('click', function (e) { | ||||||
|  |         e.preventDefault(); | ||||||
|  |         $(this).hide(); | ||||||
|  |         var form = $(this).parent().find('.comment-form') | ||||||
|  |         form.removeClass('hide'); | ||||||
|  |         assingMenuAttributes(form.find('.menu')); | ||||||
|  |     }); | ||||||
|  |     // The following part is only for diff views
 | ||||||
|  |     if ($('.repository.pull.diff').length == 0) { | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     $('.diff-detail-box.ui.sticky').sticky(); | ||||||
|  | 
 | ||||||
|  |     $('.btn-review').on('click', function(e) { | ||||||
|  |         e.preventDefault(); | ||||||
|  |         $(this).closest('.dropdown').find('.menu').toggle('visible'); | ||||||
|  |     }).closest('.dropdown').find('.link.close').on('click', function(e) { | ||||||
|  |         e.preventDefault(); | ||||||
|  |         $(this).closest('.menu').toggle('visible'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     $('.code-view .lines-code,.code-view .lines-num') | ||||||
|  |         .on('mouseenter', function() { | ||||||
|  |             var parent = $(this).closest('td'); | ||||||
|  |             $(this).closest('tr').addClass( | ||||||
|  |                 parent.hasClass('lines-num-old') || parent.hasClass('lines-code-old') | ||||||
|  |                     ? 'focus-lines-old' : 'focus-lines-new' | ||||||
|  |             ); | ||||||
|  |         }) | ||||||
|  |         .on('mouseleave', function() { | ||||||
|  |             $(this).closest('tr').removeClass('focus-lines-new focus-lines-old'); | ||||||
|  |         }); | ||||||
|  |     $('.add-code-comment').on('click', function(e) { | ||||||
|  |         e.preventDefault(); | ||||||
|  |         var isSplit = $(this).closest('.code-diff').hasClass('code-diff-split'); | ||||||
|  |         var side = $(this).data('side'); | ||||||
|  |         var idx = $(this).data('idx'); | ||||||
|  |         var path = $(this).data('path'); | ||||||
|  |         var form = $('#pull_review_add_comment').html(); | ||||||
|  |         var tr = $(this).closest('tr'); | ||||||
|  |         var ntr = tr.next(); | ||||||
|  |         if (!ntr.hasClass('add-comment')) { | ||||||
|  |             ntr = $('<tr class="add-comment">' | ||||||
|  |                     + (isSplit ? '<td class="lines-num"></td><td class="add-comment-left"></td><td class="lines-num"></td><td class="add-comment-right"></td>' | ||||||
|  |                                : '<td class="lines-num"></td><td class="lines-num"></td><td class="add-comment-left add-comment-right"></td>') | ||||||
|  |                     + '</tr>'); | ||||||
|  |             tr.after(ntr); | ||||||
|  |         } | ||||||
|  |         var td = ntr.find('.add-comment-' + side); | ||||||
|  |         var commentCloud = td.find('.comment-code-cloud'); | ||||||
|  |         if (commentCloud.length === 0) { | ||||||
|  |             td.html(form); | ||||||
|  |             commentCloud = td.find('.comment-code-cloud'); | ||||||
|  |             var id = assingMenuAttributes(commentCloud.find('.menu')); | ||||||
|  |             commentCloud.find('.tab.segment').each(function(i, item) { | ||||||
|  |                 $(item).attr('data-tab', $(item).attr('data-tab') + id); | ||||||
|  |             }); | ||||||
|  |             td.find("input[name='line']").val(idx); | ||||||
|  |             td.find("input[name='side']").val(side === "left" ? "previous":"proposed"); | ||||||
|  |             td.find("input[name='path']").val(path); | ||||||
|  |         } | ||||||
|  |         commentCloud.find('textarea').focus(); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function assingMenuAttributes(menu) { | ||||||
|  |     var id = Math.floor(Math.random() * Math.floor(1000000)); | ||||||
|  |     menu.attr('data-write', menu.attr('data-write') + id); | ||||||
|  |     menu.attr('data-preview', menu.attr('data-preview') + id); | ||||||
|  |     menu.find('.item').each(function(i, item) { | ||||||
|  |         $(item).attr('data-tab', $(item).attr('data-tab') + id); | ||||||
|  |     }); | ||||||
|  |     menu.parent().find("*[data-tab='write']").attr('data-tab', 'write' + id); | ||||||
|  |     menu.parent().find("*[data-tab='preview']").attr('data-tab', 'preview' + id); | ||||||
|  |     initCommentPreviewTab(menu.parent(".form")); | ||||||
|  |     return id; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| function initRepositoryCollaboration() { | function initRepositoryCollaboration() { | ||||||
|     console.log('initRepositoryCollaboration'); |     console.log('initRepositoryCollaboration'); | ||||||
| 
 | 
 | ||||||
|  | @ -1771,6 +1869,7 @@ $(document).ready(function () { | ||||||
|     initU2FAuth(); |     initU2FAuth(); | ||||||
|     initU2FRegister(); |     initU2FRegister(); | ||||||
|     initIssueList(); |     initIssueList(); | ||||||
|  |     initPullRequestReview(); | ||||||
| 
 | 
 | ||||||
|     // Repo clone url.
 |     // Repo clone url.
 | ||||||
|     if ($('#repo-clone-url').length > 0) { |     if ($('#repo-clone-url').length > 0) { | ||||||
|  | @ -2528,3 +2627,13 @@ function initIssueList() { | ||||||
|         }) |         }) | ||||||
|     ; |     ; | ||||||
| } | } | ||||||
|  | function cancelCodeComment(btn) { | ||||||
|  |     var form = $(btn).closest("form"); | ||||||
|  |     if(form.length > 0 && form.hasClass('comment-form')) { | ||||||
|  |         form.addClass('hide'); | ||||||
|  |         form.parent().find('.comment-form-reply').show(); | ||||||
|  |     }else { | ||||||
|  |         console.log("Hey"); | ||||||
|  |         form.closest('.comment-code-cloud').remove() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -371,7 +371,11 @@ pre, code { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .file-comment { | ||||||
|  |     font: 12px Consolas,"Liberation Mono",Menlo,Courier,monospace; | ||||||
|  |     color: rgba(0,0,0,.87); | ||||||
| 
 | 
 | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| .overflow.menu { | .overflow.menu { | ||||||
|     .items { |     .items { | ||||||
|  | @ -443,6 +447,12 @@ footer { | ||||||
| 
 | 
 | ||||||
| .hide { | .hide { | ||||||
|     display: none; |     display: none; | ||||||
|  |     &.show-outdated { | ||||||
|  |         display: none !important; | ||||||
|  |     } | ||||||
|  |     &.hide-outdated { | ||||||
|  |         display: none !important; | ||||||
|  |     } | ||||||
| } | } | ||||||
| .center { | .center { | ||||||
|     text-align: center; |     text-align: center; | ||||||
|  |  | ||||||
|  | @ -705,6 +705,21 @@ | ||||||
|                         margin-right: -1px; |                         margin-right: -1px; | ||||||
|                         font-size: 25px; |                         font-size: 25px; | ||||||
|                     } |                     } | ||||||
|  |                     &.octicon-comment { | ||||||
|  |                         margin-top: 4px; | ||||||
|  |                         margin-left: -35px; | ||||||
|  |                         font-size: 24px; | ||||||
|  |                     } | ||||||
|  |                     &.octicon-eye { | ||||||
|  |                         margin-top: 3px; | ||||||
|  |                         margin-left: -35px; | ||||||
|  |                         margin-right: 0px; | ||||||
|  |                         font-size: 22px; | ||||||
|  |                     } | ||||||
|  |                     &.octicon-x { | ||||||
|  |                         margin-left: -33px; | ||||||
|  |                         font-size: 25px; | ||||||
|  |                     } | ||||||
|                 } |                 } | ||||||
|                 .detail { |                 .detail { | ||||||
|                     font-size: 0.9rem; |                     font-size: 0.9rem; | ||||||
|  | @ -883,38 +898,44 @@ | ||||||
|                 width: 140px; |                 width: 140px; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|                 td.sha .sha.label { |         td.sha .sha.label { | ||||||
|                         margin: 0; |             margin: 0; | ||||||
|                 } |         } | ||||||
|         &.ui.basic.striped.table tbody tr:nth-child(2n) { |         &.ui.basic.striped.table tbody tr:nth-child(2n) { | ||||||
|             background-color: rgba(0, 0, 0, .02)!important; |             background-color: rgba(0, 0, 0, .02)!important; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|         #commits-table td.sha .sha.label, #repo-files-table .sha.label{ |     #commits-table td.sha .sha.label, #repo-files-table .sha.label{ | ||||||
|                 &.isSigned{ |         &.isSigned{ | ||||||
|                         border: 1px solid #BBB; |             border: 1px solid #BBB; | ||||||
|                         .detail.icon{ |             .detail.icon{ | ||||||
|                                 background: #FAFAFA; |                 background: #FAFAFA; | ||||||
|                                 margin: -6px -10px -4px 0px; |                 margin: -6px -10px -4px 0px; | ||||||
|                                 padding: 5px 3px 5px 6px; |                 padding: 5px 3px 5px 6px; | ||||||
|                                 border-left: 1px solid #BBB; |                 border-left: 1px solid #BBB; | ||||||
|                                 border-top-left-radius: 0; |                 border-top-left-radius: 0; | ||||||
|                                 border-bottom-left-radius: 0; |                 border-bottom-left-radius: 0; | ||||||
|                         } |             } | ||||||
|                 } |  | ||||||
|                 &.isSigned.isVerified{ |  | ||||||
|                         border: 1px solid #21BA45; |  | ||||||
|                         background: #21BA4518; |  | ||||||
|                         .detail.icon{ |  | ||||||
|                                 border-left: 1px solid #21BA4580; |  | ||||||
|                         } |  | ||||||
|                 } |  | ||||||
|         } |         } | ||||||
|  |         &.isSigned.isVerified{ | ||||||
|  |             border: 1px solid #21BA45; | ||||||
|  |             background: #21BA4518; | ||||||
|  |             .detail.icon{ | ||||||
|  |                 border-left: 1px solid #21BA4580; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     .diff-detail-box { |     .diff-detail-box { | ||||||
|         margin: 15px 0; |         padding: 7px 0; | ||||||
|  |         background: #fff; | ||||||
|         line-height: 30px; |         line-height: 30px; | ||||||
|  |         >div:after{ | ||||||
|  |             clear: both; | ||||||
|  |             content: ""; | ||||||
|  |             display: block; | ||||||
|  |         } | ||||||
|         ol { |         ol { | ||||||
|             clear: both; |             clear: both; | ||||||
|             padding-left: 0; |             padding-left: 0; | ||||||
|  | @ -947,8 +968,9 @@ | ||||||
|                 background-color: #dad8ff; |                 background-color: #dad8ff; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         .ui.right { |         .detail-files { | ||||||
|             margin-bottom: 15px; |             background: #fff; | ||||||
|  |             margin: 0px; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     .diff-box .header { |     .diff-box .header { | ||||||
|  |  | ||||||
							
								
								
									
										103
									
								
								public/less/_review.less
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								public/less/_review.less
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,103 @@ | ||||||
|  | .ui.button.add-code-comment { | ||||||
|  |     font-size: 14px; | ||||||
|  |     height: 16px; | ||||||
|  |     padding: 2px 0 0; | ||||||
|  |     position: relative; | ||||||
|  |     width: 16px; | ||||||
|  |     z-index: 5; | ||||||
|  |     float: left; | ||||||
|  |     margin: -2px -10px -2px -20px; | ||||||
|  |     opacity: 0; | ||||||
|  |     transition: transform 0.1s ease-in-out; | ||||||
|  |     transform: scale(1, 1); | ||||||
|  | 
 | ||||||
|  |     &:hover { | ||||||
|  |       transform: scale(1.2, 1.2); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .focus-lines-new .ui.button.add-code-comment.add-code-comment-right, | ||||||
|  | .focus-lines-old .ui.button.add-code-comment.add-code-comment-left { | ||||||
|  |     opacity: 1; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .comment-code-cloud { | ||||||
|  |     padding: 4px; | ||||||
|  |     margin: 0 auto; | ||||||
|  |     position: relative; | ||||||
|  |     border: 1px solid #f1f1f1; | ||||||
|  |     margin-top: 13px; | ||||||
|  |     margin-right: 10px; | ||||||
|  |     margin-bottom: 5px; | ||||||
|  | 
 | ||||||
|  |     &:before { | ||||||
|  |         content: " "; | ||||||
|  |         width: 0; | ||||||
|  |         height: 0; | ||||||
|  |         border-left: 13px solid transparent; | ||||||
|  |         border-right: 13px solid transparent; | ||||||
|  |         border-bottom: 13px solid #f1f1f1; | ||||||
|  |         left: 20px; | ||||||
|  |         position: absolute; | ||||||
|  |         top: -13px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .attached.tab { | ||||||
|  |         border: none; | ||||||
|  |         padding: 0; | ||||||
|  |         margin: 0; | ||||||
|  | 
 | ||||||
|  |         &.markdown { | ||||||
|  |             padding: 1em; | ||||||
|  |             min-height: 168px; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .right.menu.options .item { | ||||||
|  |         padding: 0.85714286em 0.442857em; | ||||||
|  |         cursor: pointer; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .ui.form textarea { | ||||||
|  |         border: 0px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .ui.attached.tabular.menu { | ||||||
|  |         background: #f7f7f7; | ||||||
|  |         border: 1px solid #d4d4d5; | ||||||
|  |         padding-top: 5px; | ||||||
|  |         padding-left: 5px; | ||||||
|  |         margin-top: 0px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .footer { | ||||||
|  |         border-top: 1px solid #f1f1f1; | ||||||
|  |         margin-top: 10px; | ||||||
|  | 
 | ||||||
|  |         .markdown-info { | ||||||
|  |             display: inline-block; | ||||||
|  |             margin: 5px 0; | ||||||
|  |             font-size: 12px; | ||||||
|  |             color: rgba(0,0,0,.6) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .ui.right.floated { | ||||||
|  |             padding-top: 6px; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         &:after{ | ||||||
|  |             clear: both; | ||||||
|  |             content: ""; | ||||||
|  |             display: block; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .comment-form-reply { | ||||||
|  |         margin: 0.5em !important; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .file-comment { | ||||||
|  |     font: 12px Consolas,"Liberation Mono",Menlo,Courier,monospace; | ||||||
|  |     color: rgba(0,0,0,.87); | ||||||
|  | } | ||||||
|  | @ -12,3 +12,4 @@ | ||||||
| @import "_dashboard"; | @import "_dashboard"; | ||||||
| @import "_admin"; | @import "_admin"; | ||||||
| @import "_explore"; | @import "_explore"; | ||||||
|  | @import "_review"; | ||||||
|  |  | ||||||
|  | @ -733,6 +733,22 @@ func ViewIssue(ctx *context.Context) { | ||||||
| 				ctx.ServerError("LoadDepIssueDetails", err) | 				ctx.ServerError("LoadDepIssueDetails", err) | ||||||
| 				return | 				return | ||||||
| 			} | 			} | ||||||
|  | 		} else if comment.Type == models.CommentTypeCode || comment.Type == models.CommentTypeReview { | ||||||
|  | 			if err = comment.LoadReview(); err != nil && !models.IsErrReviewNotExist(err) { | ||||||
|  | 				ctx.ServerError("LoadReview", err) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 			if comment.Review == nil { | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 			if err = comment.Review.LoadAttributes(); err != nil { | ||||||
|  | 				ctx.ServerError("Review.LoadAttributes", err) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 			if err = comment.Review.LoadCodeComments(); err != nil { | ||||||
|  | 				ctx.ServerError("Review.LoadCodeComments", err) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -1114,7 +1130,7 @@ func UpdateCommentContent(ctx *context.Context) { | ||||||
| 	if !ctx.IsSigned || (ctx.User.ID != comment.PosterID && !ctx.Repo.IsAdmin()) { | 	if !ctx.IsSigned || (ctx.User.ID != comment.PosterID && !ctx.Repo.IsAdmin()) { | ||||||
| 		ctx.Error(403) | 		ctx.Error(403) | ||||||
| 		return | 		return | ||||||
| 	} else if comment.Type != models.CommentTypeComment { | 	} else if comment.Type != models.CommentTypeComment && comment.Type != models.CommentTypeCode { | ||||||
| 		ctx.Error(204) | 		ctx.Error(204) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  | @ -1148,7 +1164,7 @@ func DeleteComment(ctx *context.Context) { | ||||||
| 	if !ctx.IsSigned || (ctx.User.ID != comment.PosterID && !ctx.Repo.IsAdmin()) { | 	if !ctx.IsSigned || (ctx.User.ID != comment.PosterID && !ctx.Repo.IsAdmin()) { | ||||||
| 		ctx.Error(403) | 		ctx.Error(403) | ||||||
| 		return | 		return | ||||||
| 	} else if comment.Type != models.CommentTypeComment { | 	} else if comment.Type != models.CommentTypeComment && comment.Type != models.CommentTypeCode { | ||||||
| 		ctx.Error(204) | 		ctx.Error(204) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -456,6 +456,12 @@ func ViewPullFiles(ctx *context.Context) { | ||||||
| 		ctx.ServerError("GetDiffRange", err) | 		ctx.ServerError("GetDiffRange", err) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	if err = diff.LoadComments(issue, ctx.User); err != nil { | ||||||
|  | 		ctx.ServerError("LoadComments", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	ctx.Data["Diff"] = diff | 	ctx.Data["Diff"] = diff | ||||||
| 	ctx.Data["DiffNotAvailable"] = diff.NumFiles() == 0 | 	ctx.Data["DiffNotAvailable"] = diff.NumFiles() == 0 | ||||||
| 
 | 
 | ||||||
|  | @ -470,7 +476,16 @@ func ViewPullFiles(ctx *context.Context) { | ||||||
| 	ctx.Data["BeforeSourcePath"] = setting.AppSubURL + "/" + path.Join(headTarget, "src", "commit", startCommitID) | 	ctx.Data["BeforeSourcePath"] = setting.AppSubURL + "/" + path.Join(headTarget, "src", "commit", startCommitID) | ||||||
| 	ctx.Data["RawPath"] = setting.AppSubURL + "/" + path.Join(headTarget, "raw", "commit", endCommitID) | 	ctx.Data["RawPath"] = setting.AppSubURL + "/" + path.Join(headTarget, "raw", "commit", endCommitID) | ||||||
| 	ctx.Data["RequireHighlightJS"] = true | 	ctx.Data["RequireHighlightJS"] = true | ||||||
| 
 | 	ctx.Data["RequireTribute"] = true | ||||||
|  | 	if ctx.Data["Assignees"], err = ctx.Repo.Repository.GetAssignees(); err != nil { | ||||||
|  | 		ctx.ServerError("GetAssignees", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	ctx.Data["CurrentReview"], err = models.GetCurrentReview(ctx.User, issue) | ||||||
|  | 	if err != nil && !models.IsErrReviewNotExist(err) { | ||||||
|  | 		ctx.ServerError("GetCurrentReview", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
| 	ctx.HTML(200, tplPullFiles) | 	ctx.HTML(200, tplPullFiles) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										151
									
								
								routers/repo/pull_review.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								routers/repo/pull_review.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,151 @@ | ||||||
|  | // Copyright 2018 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package repo | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 
 | ||||||
|  | 	"code.gitea.io/gitea/models" | ||||||
|  | 	"code.gitea.io/gitea/modules/auth" | ||||||
|  | 	"code.gitea.io/gitea/modules/context" | ||||||
|  | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | 	"code.gitea.io/gitea/modules/notification" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // CreateCodeComment will create a code comment including an pending review if required
 | ||||||
|  | func CreateCodeComment(ctx *context.Context, form auth.CodeCommentForm) { | ||||||
|  | 	issue := GetActionIssue(ctx) | ||||||
|  | 	if !issue.IsPull { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if ctx.HasError() { | ||||||
|  | 		ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) | ||||||
|  | 		ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	var comment *models.Comment | ||||||
|  | 	defer func() { | ||||||
|  | 		if comment != nil { | ||||||
|  | 			ctx.Redirect(comment.HTMLURL()) | ||||||
|  | 		} else { | ||||||
|  | 			ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  | 	signedLine := form.Line | ||||||
|  | 	if form.Side == "previous" { | ||||||
|  | 		signedLine *= -1 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	review := new(models.Review) | ||||||
|  | 	if form.IsReview { | ||||||
|  | 		var err error | ||||||
|  | 		// Check if the user has already a pending review for this issue
 | ||||||
|  | 		if review, err = models.GetCurrentReview(ctx.User, issue); err != nil { | ||||||
|  | 			if !models.IsErrReviewNotExist(err) { | ||||||
|  | 				ctx.ServerError("CreateCodeComment", err) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 			// No pending review exists
 | ||||||
|  | 			// Create a new pending review for this issue & user
 | ||||||
|  | 			if review, err = models.CreateReview(models.CreateReviewOptions{ | ||||||
|  | 				Type:     models.ReviewTypePending, | ||||||
|  | 				Reviewer: ctx.User, | ||||||
|  | 				Issue:    issue, | ||||||
|  | 			}); err != nil { | ||||||
|  | 				ctx.ServerError("CreateCodeComment", err) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	//FIXME check if line, commit and treepath exist
 | ||||||
|  | 	comment, err := models.CreateCodeComment( | ||||||
|  | 		ctx.User, | ||||||
|  | 		issue.Repo, | ||||||
|  | 		issue, | ||||||
|  | 		form.Content, | ||||||
|  | 		form.TreePath, | ||||||
|  | 		signedLine, | ||||||
|  | 		review.ID, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		ctx.ServerError("CreateCodeComment", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	// Send no notification if comment is pending
 | ||||||
|  | 	if !form.IsReview { | ||||||
|  | 		notification.Service.NotifyIssue(issue, ctx.User.ID) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
 | ||||||
|  | func SubmitReview(ctx *context.Context, form auth.SubmitReviewForm) { | ||||||
|  | 	issue := GetActionIssue(ctx) | ||||||
|  | 	if !issue.IsPull { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if ctx.HasError() { | ||||||
|  | 		ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) | ||||||
|  | 		ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	var review *models.Review | ||||||
|  | 	var err error | ||||||
|  | 
 | ||||||
|  | 	reviewType := form.ReviewType() | ||||||
|  | 	if reviewType == models.ReviewTypeUnknown { | ||||||
|  | 		ctx.ServerError("GetCurrentReview", fmt.Errorf("unknown ReviewType: %s", form.Type)) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	review, err = models.GetCurrentReview(ctx.User, issue) | ||||||
|  | 	if err != nil { | ||||||
|  | 		if !models.IsErrReviewNotExist(err) { | ||||||
|  | 			ctx.ServerError("GetCurrentReview", err) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		// No current review. Create a new one!
 | ||||||
|  | 		if review, err = models.CreateReview(models.CreateReviewOptions{ | ||||||
|  | 			Type:     reviewType, | ||||||
|  | 			Issue:    issue, | ||||||
|  | 			Reviewer: ctx.User, | ||||||
|  | 			Content:  form.Content, | ||||||
|  | 		}); err != nil { | ||||||
|  | 			ctx.ServerError("CreateReview", err) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		review.Content = form.Content | ||||||
|  | 		review.Type = reviewType | ||||||
|  | 		if err = models.UpdateReview(review); err != nil { | ||||||
|  | 			ctx.ServerError("UpdateReview", err) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	comm, err := models.CreateComment(&models.CreateCommentOptions{ | ||||||
|  | 		Type:     models.CommentTypeReview, | ||||||
|  | 		Doer:     ctx.User, | ||||||
|  | 		Content:  review.Content, | ||||||
|  | 		Issue:    issue, | ||||||
|  | 		Repo:     issue.Repo, | ||||||
|  | 		ReviewID: review.ID, | ||||||
|  | 	}) | ||||||
|  | 	if err != nil || comm == nil { | ||||||
|  | 		ctx.ServerError("CreateComment", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if err = review.Publish(); err != nil { | ||||||
|  | 		ctx.ServerError("Publish", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, issue.Index, comm.HashTag())) | ||||||
|  | } | ||||||
|  | @ -671,9 +671,15 @@ func RegisterRoutes(m *macaron.Macaron) { | ||||||
| 			m.Get(".diff", repo.DownloadPullDiff) | 			m.Get(".diff", repo.DownloadPullDiff) | ||||||
| 			m.Get(".patch", repo.DownloadPullPatch) | 			m.Get(".patch", repo.DownloadPullPatch) | ||||||
| 			m.Get("/commits", context.RepoRef(), repo.ViewPullCommits) | 			m.Get("/commits", context.RepoRef(), repo.ViewPullCommits) | ||||||
| 			m.Get("/files", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.ViewPullFiles) |  | ||||||
| 			m.Post("/merge", reqRepoWriter, bindIgnErr(auth.MergePullRequestForm{}), repo.MergePullRequest) | 			m.Post("/merge", reqRepoWriter, bindIgnErr(auth.MergePullRequestForm{}), repo.MergePullRequest) | ||||||
| 			m.Post("/cleanup", context.RepoRef(), repo.CleanUpPullRequest) | 			m.Post("/cleanup", context.RepoRef(), repo.CleanUpPullRequest) | ||||||
|  | 			m.Group("/files", func() { | ||||||
|  | 				m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.ViewPullFiles) | ||||||
|  | 				m.Group("/reviews", func() { | ||||||
|  | 					m.Post("/comments", bindIgnErr(auth.CodeCommentForm{}), repo.CreateCodeComment) | ||||||
|  | 					m.Post("/submit", bindIgnErr(auth.SubmitReviewForm{}), repo.SubmitReview) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
| 		}, repo.MustAllowPulls) | 		}, repo.MustAllowPulls) | ||||||
| 
 | 
 | ||||||
| 		m.Group("/raw", func() { | 		m.Group("/raw", func() { | ||||||
|  |  | ||||||
|  | @ -1,13 +1,16 @@ | ||||||
| {{if .DiffNotAvailable}} | {{if .DiffNotAvailable}} | ||||||
| 	<h4>{{.i18n.Tr "repo.diff.data_not_available"}}</h4> | 	<h4>{{.i18n.Tr "repo.diff.data_not_available"}}</h4> | ||||||
| {{else}} | {{else}} | ||||||
| 	<div class="diff-detail-box diff-box"> | 	<div class="diff-detail-box diff-box ui sticky"> | ||||||
| 		<div> | 		<div> | ||||||
| 			<i class="fa fa-retweet"></i> | 			<i class="fa fa-retweet"></i> | ||||||
| 			{{.i18n.Tr "repo.diff.stats_desc" .Diff.NumFiles .Diff.TotalAddition .Diff.TotalDeletion | Str2html}} | 			{{.i18n.Tr "repo.diff.stats_desc" .Diff.NumFiles .Diff.TotalAddition .Diff.TotalDeletion | Str2html}} | ||||||
| 			<div class="ui right"> | 			<div class="ui right"> | ||||||
| 				<a class="ui tiny basic toggle button" href="?style={{if .IsSplitStyle}}unified{{else}}split{{end}}">{{ if .IsSplitStyle }}{{.i18n.Tr "repo.diff.show_unified_view"}}{{else}}{{.i18n.Tr "repo.diff.show_split_view"}}{{end}}</a> | 				<a class="ui tiny basic toggle button" href="?style={{if .IsSplitStyle}}unified{{else}}split{{end}}">{{ if .IsSplitStyle }}{{.i18n.Tr "repo.diff.show_unified_view"}}{{else}}{{.i18n.Tr "repo.diff.show_split_view"}}{{end}}</a> | ||||||
| 				<a class="ui tiny basic toggle button" data-target="#diff-files">{{.i18n.Tr "repo.diff.show_diff_stats"}}</a> | 				<a class="ui tiny basic toggle button" data-target="#diff-files">{{.i18n.Tr "repo.diff.show_diff_stats"}}</a> | ||||||
|  | 				{{if $.SignedUserID}} | ||||||
|  | 					{{template "repo/diff/new_review" .}} | ||||||
|  | 				{{end}} | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| 		<ol class="detail-files hide" id="diff-files"> | 		<ol class="detail-files hide" id="diff-files"> | ||||||
|  | @ -100,21 +103,59 @@ | ||||||
| 													<tr class="{{DiffLineTypeToStr .GetType}}-code nl-{{$k}} ol-{{$k}}"> | 													<tr class="{{DiffLineTypeToStr .GetType}}-code nl-{{$k}} ol-{{$k}}"> | ||||||
| 														<td class="lines-num lines-num-old"> | 														<td class="lines-num lines-num-old"> | ||||||
| 															<span rel="{{if $line.LeftIdx}}diff-{{Sha1 $file.Name}}L{{$line.LeftIdx}}{{end}}">{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}</span> | 															<span rel="{{if $line.LeftIdx}}diff-{{Sha1 $file.Name}}L{{$line.LeftIdx}}{{end}}">{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}</span> | ||||||
|  | 
 | ||||||
| 														</td> | 														</td> | ||||||
| 														<td class="lines-code halfwidth"> | 														<td class="lines-code lines-code-old halfwidth"> | ||||||
|  | 															{{if and $.root.SignedUserID $line.CanComment}} | ||||||
|  | 																<a class="ui green button add-code-comment add-code-comment-left" data-path="{{$file.Name}}" data-side="left" data-idx="{{$line.LeftIdx}}">+</a> | ||||||
|  | 															{{end}} | ||||||
| 															<pre><code class="wrap {{if $highlightClass}}language-{{$highlightClass}}{{else}}nohighlight{{end}}">{{if $line.LeftIdx}}{{$section.GetComputedInlineDiffFor $line}}{{end}}</code></pre> | 															<pre><code class="wrap {{if $highlightClass}}language-{{$highlightClass}}{{else}}nohighlight{{end}}">{{if $line.LeftIdx}}{{$section.GetComputedInlineDiffFor $line}}{{end}}</code></pre> | ||||||
| 														</td> | 														</td> | ||||||
| 														<td class="lines-num lines-num-new"> | 														<td class="lines-num lines-num-new"> | ||||||
| 															<span rel="{{if $line.RightIdx}}diff-{{Sha1 $file.Name}}R{{$line.RightIdx}}{{end}}">{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}</span> | 															<span rel="{{if $line.RightIdx}}diff-{{Sha1 $file.Name}}R{{$line.RightIdx}}{{end}}">{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}</span> | ||||||
| 														</td> | 														</td> | ||||||
| 														<td class="lines-code halfwidth"> | 
 | ||||||
|  | 														<td class="lines-code lines-code-new halfwidth"> | ||||||
|  | 															{{if and $.root.SignedUserID $line.CanComment}} | ||||||
|  | 																<a class="ui green button add-code-comment add-code-comment-right" data-path="{{$file.Name}}" data-side="right" data-idx="{{$line.RightIdx}}">+</a> | ||||||
|  | 															{{end}} | ||||||
| 															<pre><code class="wrap {{if $highlightClass}}language-{{$highlightClass}}{{else}}nohighlight{{end}}">{{if $line.RightIdx}}{{$section.GetComputedInlineDiffFor $line}}{{end}}</code></pre> | 															<pre><code class="wrap {{if $highlightClass}}language-{{$highlightClass}}{{else}}nohighlight{{end}}">{{if $line.RightIdx}}{{$section.GetComputedInlineDiffFor $line}}{{end}}</code></pre> | ||||||
| 														</td> | 														</td> | ||||||
| 													</tr> | 													</tr> | ||||||
|  | 													{{if gt (len $line.Comments) 0}} | ||||||
|  | 														<tr class="add-code-comment"> | ||||||
|  | 															<td class="lines-num"></td> | ||||||
|  | 															<td class="add-comment-left"> | ||||||
|  | 																{{if eq $line.GetCommentSide "previous"}} | ||||||
|  | 																	<div class="field comment-code-cloud"> | ||||||
|  | 																		<div class="comment-list"> | ||||||
|  | 																			<ui class="ui comments"> | ||||||
|  | 																			{{ template "repo/diff/comments" dict "root" $ "comments" $line.Comments}} | ||||||
|  | 																			</ui> | ||||||
|  | 																		</div> | ||||||
|  | 																	{{template "repo/diff/comment_form_datahandler" dict "hidden" true "root" $ "comment" (index $line.Comments 0)}} | ||||||
|  | 																	</div> | ||||||
|  | 																{{end}} | ||||||
|  | 															</td> | ||||||
|  | 															<td class="lines-num"></td> | ||||||
|  | 															<td class="add-comment-right"> | ||||||
|  | 																{{if eq $line.GetCommentSide "proposed"}} | ||||||
|  | 																	<div class="field comment-code-cloud"> | ||||||
|  | 																		<div class="comment-list"> | ||||||
|  | 																			<ui class="ui comments"> | ||||||
|  | 																			{{ template "repo/diff/comments" dict "root" $ "comments" $line.Comments}} | ||||||
|  | 																			</ui> | ||||||
|  | 																		</div> | ||||||
|  | 																		{{template "repo/diff/comment_form_datahandler" dict "hidden" true "root" $ "comment" (index $line.Comments 0)}} | ||||||
|  | 																	</div> | ||||||
|  | 																{{end}} | ||||||
|  | 															</td> | ||||||
|  | 														</tr> | ||||||
|  | 													{{end}} | ||||||
| 												{{end}} | 												{{end}} | ||||||
| 											{{end}} | 											{{end}} | ||||||
| 										{{else}} | 										{{else}} | ||||||
| 											{{template "repo/diff/section_unified" .}} | 											{{template "repo/diff/section_unified" dict "file" . "root" $}} | ||||||
| 										{{end}} | 										{{end}} | ||||||
| 									</tbody> | 									</tbody> | ||||||
| 								</table> | 								</table> | ||||||
|  | @ -135,6 +176,28 @@ | ||||||
| 		</div> | 		</div> | ||||||
| 	{{end}} | 	{{end}} | ||||||
| 
 | 
 | ||||||
|  | 	<div id="pull_review_add_comment" class="hide"> | ||||||
|  | 		{{template "repo/diff/new_comment" dict "root" .}} | ||||||
|  | 	</div> | ||||||
|  | 	<div class="hide" id="edit-content-form"> | ||||||
|  | 		<div class="ui comment form"> | ||||||
|  | 			<div class="ui top attached tabular menu"> | ||||||
|  | 				<a class="active write item">{{$.i18n.Tr "write"}}</a> | ||||||
|  | 				<a class="preview item" data-url="{{AppSubUrl}}/api/v1/markdown" data-context="{{$.RepoLink}}">{{$.i18n.Tr "preview"}}</a> | ||||||
|  | 			</div> | ||||||
|  | 			<div class="ui bottom attached active write tab segment"> | ||||||
|  | 				<textarea tabindex="1" id="content" name="content"></textarea> | ||||||
|  | 			</div> | ||||||
|  | 			<div class="ui bottom attached tab preview segment markdown"> | ||||||
|  | 			{{$.i18n.Tr "loading"}} | ||||||
|  | 			</div> | ||||||
|  | 			<div class="text right edit buttons"> | ||||||
|  | 				<div class="ui basic blue cancel button" tabindex="3">{{.i18n.Tr "repo.issues.cancel"}}</div> | ||||||
|  | 				<div class="ui green save button" tabindex="2">{{.i18n.Tr "repo.issues.save"}}</div> | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
|  | 	</div> | ||||||
|  | 
 | ||||||
| 	{{if .IsSplitStyle}} | 	{{if .IsSplitStyle}} | ||||||
| 		<script> | 		<script> | ||||||
| 			(function() { | 			(function() { | ||||||
|  |  | ||||||
							
								
								
									
										45
									
								
								templates/repo/diff/comment_form.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								templates/repo/diff/comment_form.tmpl
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | ||||||
|  | {{if $.root.SignedUserID}} | ||||||
|  | 	{{if $.hidden}} | ||||||
|  | 		<button class="comment-form-reply ui green labeled icon tiny button"><i class="reply icon"></i> {{$.root.i18n.Tr "repo.diff.comment.reply"}}</button> | ||||||
|  | 	{{end}} | ||||||
|  | 	<form class="ui form {{if $.hidden}}hide comment-form{{end}}" action="{{$.root.Issue.HTMLURL}}/files/reviews/comments" method="post"> | ||||||
|  | 	{{$.root.CsrfTokenHtml}} | ||||||
|  | 		<input type="hidden" name="side" value="{{if $.Side}}{{$.Side}}{{end}}"> | ||||||
|  | 		<input type="hidden" name="line" value="{{if $.Line}}{{$.Line}}{{end}}"> | ||||||
|  | 		<input type="hidden" name="path" value="{{if $.File}}{{$.File}}{{end}}"> | ||||||
|  | 		<input type="hidden" name="diff_start_cid"> | ||||||
|  | 		<input type="hidden" name="diff_end_cid"> | ||||||
|  | 		<input type="hidden" name="diff_base_cid"> | ||||||
|  | 		<div class="ui top attached tabular menu" {{if not $.hidden}}onload="assingMenuAttributes(this)" {{end}}data-write="write" data-preview="preview"> | ||||||
|  | 			<a class="active item" data-tab="write">{{$.root.i18n.Tr "write"}}</a> | ||||||
|  | 			<a class="item" data-tab="preview" data-url="{{$.root.AppSubUrl}}/api/v1/markdown" data-context="{{$.root.RepoLink}}">{{$.root.i18n.Tr "preview"}}</a> | ||||||
|  | 		</div> | ||||||
|  | 		<div class="ui bottom attached active tab segment" data-tab="write"> | ||||||
|  | 			<div class="field"> | ||||||
|  | 				<textarea name="content" placeholder="{{$.root.i18n.Tr "repo.diff.comment.placeholder"}}"></textarea> | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
|  | 		<div class="ui bottom attached tab segment markdown" data-tab="preview"> | ||||||
|  | 		{{.i18n.Tr "loading"}} | ||||||
|  | 		</div> | ||||||
|  | 		<div class="footer"> | ||||||
|  | 			<span class="markdown-info"><i class="octicon octicon-markdown"></i> {{$.root.i18n.Tr "repo.diff.comment.markdown_info"}}</span> | ||||||
|  | 			<div class="ui right floated"> | ||||||
|  | 				{{if not $.reply}} | ||||||
|  | 					{{if $.root.CurrentReview}} | ||||||
|  | 						<button name="is_review" value="true" type="submit" | ||||||
|  | 								class="ui submit green tiny button btn-add-comment">{{$.root.i18n.Tr "repo.diff.comment.add_review_comment"}}</button> | ||||||
|  | 					{{else}} | ||||||
|  | 						<button name="is_review" value="true"  type="submit" | ||||||
|  | 								class="ui submit green tiny button btn-start-review">{{$.root.i18n.Tr "repo.diff.comment.start_review"}}</button> | ||||||
|  | 					{{end}} | ||||||
|  | 				{{end}} | ||||||
|  | 				<button type="submit" | ||||||
|  | 					class="ui submit tiny basic button btn-add-single">{{$.root.i18n.Tr "repo.diff.comment.add_single_comment"}}</button> | ||||||
|  | 				{{if or (not $.HasComments) $.hidden}} | ||||||
|  | 					<button type="button" class="ui submit tiny basic button btn-cancel" onclick="cancelCodeComment(this);">{{$.root.i18n.Tr "cancel"}}</button> | ||||||
|  | 				{{end}} | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
|  | 	</form> | ||||||
|  | {{end}} | ||||||
							
								
								
									
										7
									
								
								templates/repo/diff/comment_form_datahandler.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								templates/repo/diff/comment_form_datahandler.tmpl
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | ||||||
|  | {{if $.comment}} | ||||||
|  | 	{{ template "repo/diff/comment_form" dict "root" $.root "hidden" $.hidden "reply" $.reply "Line" $.comment.UnsignedLine "File" $.comment.TreePath "Side" $.comment.DiffSide "HasComments" true}} | ||||||
|  | {{else if $.root}} | ||||||
|  | 	{{ template "repo/diff/comment_form" $}} | ||||||
|  | {{else}} | ||||||
|  | 	{{ template "repo/diff/comment_form" dict "root" $}} | ||||||
|  | {{end}} | ||||||
							
								
								
									
										51
									
								
								templates/repo/diff/comments.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								templates/repo/diff/comments.tmpl
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | ||||||
|  | {{range .comments}} | ||||||
|  | 
 | ||||||
|  | {{ $createdStr:= TimeSinceUnix .CreatedUnix $.root.Lang }} | ||||||
|  | <div class="comment" id="{{.HashTag}}"> | ||||||
|  | 	<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}> | ||||||
|  | 		<img src="{{.Poster.RelAvatarLink}}"> | ||||||
|  | 	</a> | ||||||
|  | 	<div class="content"> | ||||||
|  | 		<div class="ui top attached header"> | ||||||
|  | 			<span class="text grey"><a {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}>{{.Poster.Name}}</a> {{$.root.i18n.Tr "repo.issues.commented_at" .HashTag $createdStr | Safe}}</span> | ||||||
|  | 			<div class="ui right actions"> | ||||||
|  | 			{{if and .Review}} | ||||||
|  | 				{{if eq .Review.Type 0}} | ||||||
|  | 					<div class="item tag"> | ||||||
|  | 					{{$.root.i18n.Tr "repo.issues.review.pending"}} | ||||||
|  | 					</div> | ||||||
|  | 				{{else}} | ||||||
|  | 					<div class="item tag"> | ||||||
|  | 					{{$.root.i18n.Tr "repo.issues.review.review"}} | ||||||
|  | 					</div> | ||||||
|  | 				{{end}} | ||||||
|  | 			{{end}} | ||||||
|  | 			{{template "repo/issue/view_content/add_reaction" Dict "ctx" $ "ActionURL" (Printf "%s/comments/%d/reactions" $.root.RepoLink .ID) }} | ||||||
|  | 			{{if or $.root.IsRepositoryAdmin (eq .Poster.ID $.root.SignedUserID)}} | ||||||
|  | 				<div class="item action"> | ||||||
|  | 					<a class="edit-content" href="#"><i class="octicon octicon-pencil"></i></a> | ||||||
|  | 					<a class="delete-comment" href="#" data-comment-id={{.HashTag}} data-url="{{$.root.RepoLink}}/comments/{{.ID}}/delete" data-locale="{{$.root.i18n.Tr "repo.issues.delete_comment_confirm"}}"><i class="octicon octicon-x"></i></a> | ||||||
|  | 				</div> | ||||||
|  | 			{{end}} | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
|  | 		<div class="ui attached segment"> | ||||||
|  | 			<div class="render-content markdown has-emoji"> | ||||||
|  | 			{{if .RenderedContent}} | ||||||
|  | 				{{.RenderedContent|Str2html}} | ||||||
|  | 			{{else}} | ||||||
|  | 				<span class="no-content">{{$.root.i18n.Tr "repo.issues.no_content"}}</span> | ||||||
|  | 			{{end}} | ||||||
|  | 			</div> | ||||||
|  | 			<div class="raw-content hide">{{.Content}}</div> | ||||||
|  | 			<div class="edit-content-zone hide" data-write="issuecomment-{{.ID}}-write" data-preview="issuecomment-{{.ID}}-preview" data-update-url="{{$.root.RepoLink}}/comments/{{.ID}}" data-context="{{$.root.RepoLink}}"></div> | ||||||
|  | 		</div> | ||||||
|  | 		{{$reactions := .Reactions.GroupByType}} | ||||||
|  | 		{{if $reactions}} | ||||||
|  | 			<div class="ui attached segment reactions"> | ||||||
|  | 			{{template "repo/issue/view_content/reactions" Dict "ctx" $ "ActionURL" (Printf "%s/comments/%d/reactions" $.root.RepoLink .ID) "Reactions" $reactions }} | ||||||
|  | 			</div> | ||||||
|  | 		{{end}} | ||||||
|  | 	</div> | ||||||
|  | </div> | ||||||
|  | {{end}} | ||||||
							
								
								
									
										3
									
								
								templates/repo/diff/new_comment.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								templates/repo/diff/new_comment.tmpl
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | ||||||
|  | <div class="field comment-code-cloud"> | ||||||
|  | 	{{template "repo/diff/comment_form_datahandler" .}} | ||||||
|  | </div> | ||||||
							
								
								
									
										28
									
								
								templates/repo/diff/new_review.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								templates/repo/diff/new_review.tmpl
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | ||||||
|  | <div class="ui top right pointing dropdown custom" id="review-box"> | ||||||
|  | 	<div class="ui tiny green button btn-review"> | ||||||
|  | 		<span class="text">{{.i18n.Tr "repo.diff.review"}}</span> | ||||||
|  | 		<i class="dropdown icon"></i> | ||||||
|  | 	</div> | ||||||
|  | 	<div class="menu"> | ||||||
|  | 		<div class="ui clearing segment"> | ||||||
|  | 			<form class="ui form" action="{{.Link}}/reviews/submit" method="post"> | ||||||
|  | 			{{.CsrfTokenHtml}} | ||||||
|  | 				<i class="ui right floated link icon close"></i> | ||||||
|  | 				<div class="header"> | ||||||
|  | 				{{$.i18n.Tr "repo.diff.review.header"}} | ||||||
|  | 				</div> | ||||||
|  | 				<div class="ui field"> | ||||||
|  | 					<textarea name="content" tabindex="0" rows="2" | ||||||
|  | 							  placeholder="{{$.i18n.Tr "repo.diff.review.placeholder"}}"></textarea> | ||||||
|  | 				</div> | ||||||
|  | 				<div class="ui divider"></div> | ||||||
|  | 				<button type="submit" name="type" value="approve" | ||||||
|  | 						class="ui submit green tiny button btn-submit">{{$.i18n.Tr "repo.diff.review.approve"}}</button> | ||||||
|  | 				<button type="submit" name="type" value="comment" | ||||||
|  | 						class="ui submit tiny basic button btn-submit">{{$.i18n.Tr "repo.diff.review.comment"}}</button> | ||||||
|  | 				<button type="submit" name="type" value="reject" | ||||||
|  | 						class="ui submit red tiny button btn-submit">{{$.i18n.Tr "repo.diff.review.reject"}}</button> | ||||||
|  | 			</form> | ||||||
|  | 		</div> | ||||||
|  | 	</div> | ||||||
|  | </div> | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| {{$file := .}} | {{$file := .file}} | ||||||
| {{$highlightClass := $file.GetHighlightClass}} | {{$highlightClass := $file.GetHighlightClass}} | ||||||
| {{range $j, $section := $file.Sections}} | {{range $j, $section := $file.Sections}} | ||||||
| 	{{range $k, $line := $section.Lines}} | 	{{range $k, $line := $section.Lines}} | ||||||
|  | @ -15,9 +15,27 @@ | ||||||
| 				<span rel="{{if $line.RightIdx}}diff-{{Sha1 $file.Name}}R{{$line.RightIdx}}{{end}}">{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}</span> | 				<span rel="{{if $line.RightIdx}}diff-{{Sha1 $file.Name}}R{{$line.RightIdx}}{{end}}">{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}</span> | ||||||
| 			</td> | 			</td> | ||||||
| 			{{end}} | 			{{end}} | ||||||
| 			<td class="lines-code"> | 			<td class="lines-code {{if (not $line.RightIdx)}}lines-code-old{{end}}"> | ||||||
|  | 				{{if and $.root.SignedUserID $line.CanComment}} | ||||||
|  | 					<a class="ui green button add-code-comment add-code-comment-{{if $line.RightIdx}}right{{else}}left{{end}}" data-path="{{$file.Name}}" data-side="{{if $line.RightIdx}}right{{else}}left{{end}}" data-idx="{{if $line.RightIdx}}{{$line.RightIdx}}{{else}}{{$line.LeftIdx}}{{end}}">+</a> | ||||||
|  | 				{{end}} | ||||||
| 				<pre><code class="wrap {{if $highlightClass}}language-{{$highlightClass}}{{else}}nohighlight{{end}}">{{$section.GetComputedInlineDiffFor $line}}</code></pre> | 				<pre><code class="wrap {{if $highlightClass}}language-{{$highlightClass}}{{else}}nohighlight{{end}}">{{$section.GetComputedInlineDiffFor $line}}</code></pre> | ||||||
| 			</td> | 			</td> | ||||||
| 		</tr> | 		</tr> | ||||||
|  | 		{{if gt (len $line.Comments) 0}} | ||||||
|  | 		<tr> | ||||||
|  | 			<td colspan="2" class="lines-num"></td> | ||||||
|  | 			<td class="add-comment-left add-comment-right"> | ||||||
|  | 				<div class="field comment-code-cloud"> | ||||||
|  | 					<div class="comment-list"> | ||||||
|  | 						<ui class="ui comments"> | ||||||
|  | 						{{ template "repo/diff/comments" dict "root" $.root "comments" $line.Comments}} | ||||||
|  | 						</ui> | ||||||
|  | 					</div> | ||||||
|  | 					{{template "repo/diff/comment_form_datahandler" dict "hidden" true "root" $.root "comment" (index $line.Comments 0)}} | ||||||
|  | 				</div> | ||||||
|  | 			</td> | ||||||
|  | 		</tr> | ||||||
|  | 		{{end}} | ||||||
| 	{{end}} | 	{{end}} | ||||||
| {{end}} | {{end}} | ||||||
|  |  | ||||||
|  | @ -30,7 +30,7 @@ | ||||||
| 				<div class="ui top attached tabular menu" data-write="write" data-preview="preview" data-diff="diff"> | 				<div class="ui top attached tabular menu" data-write="write" data-preview="preview" data-diff="diff"> | ||||||
| 					<a class="active item" data-tab="write"><i class="octicon octicon-code"></i> {{if .IsNewFile}}{{.i18n.Tr "repo.editor.new_file"}}{{else}}{{.i18n.Tr "repo.editor.edit_file"}}{{end}}</a> | 					<a class="active item" data-tab="write"><i class="octicon octicon-code"></i> {{if .IsNewFile}}{{.i18n.Tr "repo.editor.new_file"}}{{else}}{{.i18n.Tr "repo.editor.edit_file"}}{{end}}</a> | ||||||
| 					{{if not .IsNewFile}} | 					{{if not .IsNewFile}} | ||||||
| 					<a class="item" data-tab="preview" data-url="{{AppSubUrl}}/api/v1/markdown" data-context="{{.RepoLink}}/src/{{.BranchNameSubURL}}" data-preview-file-modes="{{.PreviewableFileModes}}"><i class="octicon octicon-eye"></i> {{.i18n.Tr "repo.release.preview"}}</a> | 					<a class="item" data-tab="preview" data-url="{{AppSubUrl}}/api/v1/markdown" data-context="{{.RepoLink}}/src/{{.BranchNameSubURL}}" data-preview-file-modes="{{.PreviewableFileModes}}"><i class="octicon octicon-eye"></i> {{.i18n.Tr "preview"}}</a> | ||||||
| 					<a class="item" data-tab="diff" data-url="{{.RepoLink}}/_preview/{{.BranchName}}/{{.TreePath}}" data-context="{{.BranchLink}}"><i class="octicon octicon-diff"></i> {{.i18n.Tr "repo.editor.preview_changes"}}</a> | 					<a class="item" data-tab="diff" data-url="{{.RepoLink}}/_preview/{{.BranchName}}/{{.TreePath}}" data-context="{{.BranchLink}}"><i class="octicon octicon-diff"></i> {{.i18n.Tr "repo.editor.preview_changes"}}</a> | ||||||
| 					{{end}} | 					{{end}} | ||||||
| 				</div> | 				</div> | ||||||
|  | @ -44,10 +44,10 @@ | ||||||
| {{.FileContent}}</textarea> | {{.FileContent}}</textarea> | ||||||
| 				</div> | 				</div> | ||||||
| 				<div class="ui bottom attached tab segment markdown" data-tab="preview"> | 				<div class="ui bottom attached tab segment markdown" data-tab="preview"> | ||||||
| 					{{.i18n.Tr "repo.release.loading"}} | 					{{.i18n.Tr "loading"}} | ||||||
| 				</div> | 				</div> | ||||||
| 				<div class="ui bottom attached tab segment diff" data-tab="diff"> | 				<div class="ui bottom attached tab segment diff" data-tab="diff"> | ||||||
| 					{{.i18n.Tr "repo.release.loading"}} | 					{{.i18n.Tr "loading"}} | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 			{{template "repo/editor/commit_form" .}} | 			{{template "repo/editor/commit_form" .}} | ||||||
|  |  | ||||||
|  | @ -1,14 +1,14 @@ | ||||||
| <div class="field"> | <div class="field"> | ||||||
| 	<div class="ui top attached tabular menu" data-write="write" data-preview="preview"> | 	<div class="ui top attached tabular menu" data-write="write" data-preview="preview"> | ||||||
| 		<a class="active item" data-tab="write">{{.i18n.Tr "repo.release.write"}}</a> | 		<a class="active item" data-tab="write">{{.i18n.Tr "write"}}</a> | ||||||
| 		<a class="item" data-tab="preview" data-url="{{AppSubUrl}}/api/v1/markdown" data-context="{{.RepoLink}}">{{.i18n.Tr "repo.release.preview"}}</a> | 		<a class="item" data-tab="preview" data-url="{{AppSubUrl}}/api/v1/markdown" data-context="{{.RepoLink}}">{{.i18n.Tr "preview"}}</a> | ||||||
| 	</div> | 	</div> | ||||||
| 	<div class="ui bottom attached active tab segment" data-tab="write"> | 	<div class="ui bottom attached active tab segment" data-tab="write"> | ||||||
| 		<textarea id="content" class="edit_area js-quick-submit" name="content" tabindex="4" data-id="issue-{{.RepoName}}" data-url="{{AppSubUrl}}/api/v1/markdown" data-context="{{.Repo.RepoLink}}"> | 		<textarea id="content" class="edit_area js-quick-submit" name="content" tabindex="4" data-id="issue-{{.RepoName}}" data-url="{{AppSubUrl}}/api/v1/markdown" data-context="{{.Repo.RepoLink}}"> | ||||||
| {{if .IssueTemplate}}{{.IssueTemplate}}{{else if .PullRequestTemplate}}{{.PullRequestTemplate}}{{else}}{{.content}}{{end}}</textarea> | {{if .IssueTemplate}}{{.IssueTemplate}}{{else if .PullRequestTemplate}}{{.PullRequestTemplate}}{{else}}{{.content}}{{end}}</textarea> | ||||||
| 	</div> | 	</div> | ||||||
| 	<div class="ui bottom attached tab segment markdown" data-tab="preview"> | 	<div class="ui bottom attached tab segment markdown" data-tab="preview"> | ||||||
| 		{{.i18n.Tr "repo.release.loading"}} | 		{{.i18n.Tr "loading"}} | ||||||
| 	</div> | 	</div> | ||||||
| </div> | </div> | ||||||
| {{if .IsAttachmentEnabled}} | {{if .IsAttachmentEnabled}} | ||||||
|  |  | ||||||
|  | @ -111,14 +111,14 @@ | ||||||
| <div class="hide" id="edit-content-form"> | <div class="hide" id="edit-content-form"> | ||||||
| 	<div class="ui comment form"> | 	<div class="ui comment form"> | ||||||
| 		<div class="ui top attached tabular menu"> | 		<div class="ui top attached tabular menu"> | ||||||
| 			<a class="active write item">{{$.i18n.Tr "repo.release.write"}}</a> | 			<a class="active write item">{{$.i18n.Tr "write"}}</a> | ||||||
| 			<a class="preview item" data-url="{{AppSubUrl}}/api/v1/markdown" data-context="{{$.RepoLink}}">{{$.i18n.Tr "repo.release.preview"}}</a> | 			<a class="preview item" data-url="{{AppSubUrl}}/api/v1/markdown" data-context="{{$.RepoLink}}">{{$.i18n.Tr "preview"}}</a> | ||||||
| 		</div> | 		</div> | ||||||
| 		<div class="ui bottom attached active write tab segment"> | 		<div class="ui bottom attached active write tab segment"> | ||||||
| 			<textarea tabindex="1" id="content" name="content"></textarea> | 			<textarea tabindex="1" id="content" name="content"></textarea> | ||||||
| 		</div> | 		</div> | ||||||
| 		<div class="ui bottom attached tab preview segment markdown"> | 		<div class="ui bottom attached tab preview segment markdown"> | ||||||
| 			{{$.i18n.Tr "repo.release.loading"}} | 			{{$.i18n.Tr "loading"}} | ||||||
| 		</div> | 		</div> | ||||||
| 		<div class="text right edit buttons"> | 		<div class="text right edit buttons"> | ||||||
| 			<div class="ui basic blue cancel button" tabindex="3">{{.i18n.Tr "repo.issues.cancel"}}</div> | 			<div class="ui basic blue cancel button" tabindex="3">{{.i18n.Tr "repo.issues.cancel"}}</div> | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| {{range .Issue.Comments}} | {{range .Issue.Comments}} | ||||||
| 	{{ $createdStr:= TimeSinceUnix .CreatedUnix $.Lang }} | 	{{ $createdStr:= TimeSinceUnix .CreatedUnix $.Lang }} | ||||||
| 
 | 
 | ||||||
| 	<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 12 = START_TRACKING, 13 = STOP_TRACKING, 14 = ADD_TIME_MANUAL, 16 = ADDED_DEADLINE, 17 = MODIFIED_DEADLINE, 18 = REMOVED_DEADLINE, 19 = ADD_DEPENDENCY, 20 = REMOVE_DEPENDENCY --> | 	<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 12 = START_TRACKING, 13 = STOP_TRACKING, 14 = ADD_TIME_MANUAL, 16 = ADDED_DEADLINE, 17 = MODIFIED_DEADLINE, 18 = REMOVED_DEADLINE, 19 = ADD_DEPENDENCY, 20 = REMOVE_DEPENDENCY, 21 = CODE, 22 = REVIEW --> | ||||||
| 	{{if eq .Type 0}} | 	{{if eq .Type 0}} | ||||||
| 		<div class="comment" id="{{.HashTag}}"> | 		<div class="comment" id="{{.HashTag}}"> | ||||||
| 			<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}> | 			<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}> | ||||||
|  | @ -260,5 +260,93 @@ | ||||||
| 			 	<span class="text grey"><a href="{{$.RepoLink}}/issues/{{.DependentIssue.Index}}">#{{.DependentIssue.Index}} {{.DependentIssue.Title}}</a></span> | 			 	<span class="text grey"><a href="{{$.RepoLink}}/issues/{{.DependentIssue.Index}}">#{{.DependentIssue.Index}} {{.DependentIssue.Title}}</a></span> | ||||||
| 	     	</div> | 	     	</div> | ||||||
|      	</div> |      	</div> | ||||||
|      {{end}} | 	{{else if eq .Type 22}} | ||||||
|  | 	    <div class="event" id="{{.HashTag}}"> | ||||||
|  | 	    	<span class="octicon octicon-{{.Review.Type.Icon}}"></span> | ||||||
|  | 	    	<a class="ui avatar image" href="{{.Poster.HomeLink}}"> | ||||||
|  | 	    		<img src="{{.Poster.RelAvatarLink}}"> | ||||||
|  | 	    	</a> | ||||||
|  | 	    	<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> | ||||||
|  | 	    		{{if eq .Review.Type 1}} | ||||||
|  | 	    			{{$.i18n.Tr "repo.issues.review.approve" $createdStr | Safe}} | ||||||
|  | 	    		{{else if eq .Review.Type 2}} | ||||||
|  | 	    			{{$.i18n.Tr "repo.issues.review.comment" $createdStr | Safe}} | ||||||
|  | 	    		{{else if eq .Review.Type 3}} | ||||||
|  | 	    			{{$.i18n.Tr "repo.issues.review.reject" $createdStr | Safe}} | ||||||
|  | 	    		{{else}} | ||||||
|  | 	    			{{$.i18n.Tr "repo.issues.review.comment" $createdStr | Safe}} | ||||||
|  | 	    		{{end}} | ||||||
|  | 	    	</span> | ||||||
|  | 	    	{{if .Content}} | ||||||
|  | 	    		<div class="detail"> | ||||||
|  | 					<span class="octicon octicon-quote"></span> | ||||||
|  | 	    			<span class="text grey">{{.Content}}</span> | ||||||
|  | 	    		</div> | ||||||
|  | 	    	{{end}} | ||||||
|  | 			{{ range $filename, $lines := .Review.CodeComments}} | ||||||
|  | 				{{range $line, $comms := $lines}} | ||||||
|  | 						<div class="ui segments"> | ||||||
|  | 							<div class="ui segment"> | ||||||
|  | 								{{$invalid := (index $comms 0).Invalidated}} | ||||||
|  | 							{{if $invalid}} | ||||||
|  | 								<button id="show-outdated-{{(index $comms 0).ID}}" data-comment="{{(index $comms 0).ID}}" class="ui compact right labeled button show-outdated"> | ||||||
|  | 									<i class="octicon octicon-fold"></i> | ||||||
|  | 									{{$.i18n.Tr "repo.issues.review.show_outdated"}} | ||||||
|  | 								</button> | ||||||
|  | 								<button id="hide-outdated-{{(index $comms 0).ID}}" data-comment="{{(index $comms 0).ID}}" class="hide ui compact right labeled button hide-outdated"> | ||||||
|  | 									<i class="octicon octicon-fold"></i> | ||||||
|  | 									{{$.i18n.Tr "repo.issues.review.hide_outdated"}} | ||||||
|  | 								</button> | ||||||
|  | 							{{end}} | ||||||
|  | 								<a href="{{(index $comms 0).CodeCommentURL}}" class="file-comment">{{$filename}}</a> | ||||||
|  | 							</div> | ||||||
|  | 							{{$diff := ((index $comms 0).MustAsDiff)}} | ||||||
|  | 							{{if $diff}} | ||||||
|  | 								{{$file := (index $diff.Files 0)}} | ||||||
|  | 								<div id="code-preview-{{(index $comms 0).ID}}" class="ui table segment{{if $invalid}} hide{{end}}"> | ||||||
|  | 									<div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}}"> | ||||||
|  | 										<div class="file-body file-code code-view code-diff code-diff-unified"> | ||||||
|  | 											<table> | ||||||
|  | 												<tbody> | ||||||
|  | 													{{template "repo/diff/section_unified" dict "file" $file "root" $}} | ||||||
|  | 												</tbody> | ||||||
|  | 											</table> | ||||||
|  | 										</div> | ||||||
|  | 									</div> | ||||||
|  | 								</div> | ||||||
|  | 							{{end}} | ||||||
|  | 							<div id="code-comments-{{(index $comms 0).ID}}" class="ui segment{{if $invalid}} hide{{end}}"> | ||||||
|  | 								<div class="ui comments"> | ||||||
|  | 									{{range $comms}} | ||||||
|  | 										{{ $createdSubStr:= TimeSinceUnix .CreatedUnix $.Lang }} | ||||||
|  | 										<div class="comment" id="{{.HashTag}}"> | ||||||
|  | 											<a class="avatar"> | ||||||
|  | 												<img src="{{.Poster.RelAvatarLink}}"> | ||||||
|  | 											</a> | ||||||
|  | 											<div class="content"> | ||||||
|  | 												<a class="author" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}>{{.Poster.Name}}</a> | ||||||
|  | 												<div class="metadata"> | ||||||
|  | 													<span class="date">{{$.i18n.Tr "repo.issues.commented_at" .HashTag $createdSubStr | Safe}}</span> | ||||||
|  | 												</div> | ||||||
|  | 												<div class="text"> | ||||||
|  | 													<div class="render-content markdown has-emoji"> | ||||||
|  | 													{{if .RenderedContent}} | ||||||
|  | 														{{.RenderedContent|Str2html}} | ||||||
|  | 													{{else}} | ||||||
|  | 														<span class="no-content">{{$.i18n.Tr "repo.issues.no_content"}}</span> | ||||||
|  | 													{{end}} | ||||||
|  | 													</div> | ||||||
|  | 													<div class="raw-content hide">{{.Content}}</div> | ||||||
|  | 												</div> | ||||||
|  | 											</div> | ||||||
|  | 										</div> | ||||||
|  | 									{{end}} | ||||||
|  | 								</div> | ||||||
|  | 								{{template "repo/diff/comment_form_datahandler" dict "hidden" true "reply" true "root" $ "comment" (index $comms 0)}} | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 				{{end}} | ||||||
|  | 			{{end}} | ||||||
|  | 	    </div> | ||||||
|  | 	{{end}} | ||||||
| {{end}} | {{end}} | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue