Add attachment support for code review comments (#29220)

Fixes #27960, #24411, #12183

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
pull/29392/head^2
Jimmy Praet 9 months ago committed by GitHub
parent 1ef87773b1
commit 2e33671f2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 37
      models/issues/comment.go
  2. 1
      routers/api/v1/repo/pull_review.go
  3. 4
      routers/web/repo/issue.go
  4. 13
      routers/web/repo/pull.go
  5. 19
      routers/web/repo/pull_review.go
  6. 2
      routers/web/repo/pull_review_test.go
  7. 1
      services/forms/repo_form.go
  8. 1
      services/mailer/incoming/incoming_handler.go
  9. 7
      services/pull/review.go
  10. 5
      templates/repo/diff/box.tmpl
  11. 6
      templates/repo/diff/comment_form.tmpl
  12. 5
      templates/repo/diff/comments.tmpl
  13. 3
      templates/repo/issue/view_content/conversation.tmpl
  14. 111
      web_src/js/features/common-global.js
  15. 7
      web_src/js/features/repo-issue.js

@ -855,6 +855,9 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
// Check comment type. // Check comment type.
switch opts.Type { switch opts.Type {
case CommentTypeCode: case CommentTypeCode:
if err = updateAttachments(ctx, opts, comment); err != nil {
return err
}
if comment.ReviewID != 0 { if comment.ReviewID != 0 {
if comment.Review == nil { if comment.Review == nil {
if err := comment.loadReview(ctx); err != nil { if err := comment.loadReview(ctx); err != nil {
@ -872,22 +875,9 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
} }
fallthrough fallthrough
case CommentTypeReview: case CommentTypeReview:
// Check attachments if err = updateAttachments(ctx, opts, comment); err != nil {
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) return err
if err != nil {
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err)
}
for i := range attachments {
attachments[i].IssueID = opts.Issue.ID
attachments[i].CommentID = comment.ID
// No assign value could be 0, so ignore AllCols().
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Update(attachments[i]); err != nil {
return fmt.Errorf("update attachment [%d]: %w", attachments[i].ID, err)
}
} }
comment.Attachments = attachments
case CommentTypeReopen, CommentTypeClose: case CommentTypeReopen, CommentTypeClose:
if err = repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.Issue.IsPull, true); err != nil { if err = repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.Issue.IsPull, true); err != nil {
return err return err
@ -897,6 +887,23 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
return UpdateIssueCols(ctx, opts.Issue, "updated_unix") return UpdateIssueCols(ctx, opts.Issue, "updated_unix")
} }
func updateAttachments(ctx context.Context, opts *CreateCommentOptions, comment *Comment) error {
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments)
if err != nil {
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err)
}
for i := range attachments {
attachments[i].IssueID = opts.Issue.ID
attachments[i].CommentID = comment.ID
// No assign value could be 0, so ignore AllCols().
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Update(attachments[i]); err != nil {
return fmt.Errorf("update attachment [%d]: %w", attachments[i].ID, err)
}
}
comment.Attachments = attachments
return nil
}
func createDeadlineComment(ctx context.Context, doer *user_model.User, issue *Issue, newDeadlineUnix timeutil.TimeStamp) (*Comment, error) { func createDeadlineComment(ctx context.Context, doer *user_model.User, issue *Issue, newDeadlineUnix timeutil.TimeStamp) (*Comment, error) {
var content string var content string
var commentType CommentType var commentType CommentType

@ -362,6 +362,7 @@ func CreatePullReview(ctx *context.APIContext) {
true, // pending review true, // pending review
0, // no reply 0, // no reply
opts.CommitID, opts.CommitID,
nil,
); err != nil { ); err != nil {
ctx.Error(http.StatusInternalServerError, "CreateCodeComment", err) ctx.Error(http.StatusInternalServerError, "CreateCodeComment", err)
return return

@ -1718,6 +1718,10 @@ func ViewIssue(ctx *context.Context) {
for _, codeComments := range comment.Review.CodeComments { for _, codeComments := range comment.Review.CodeComments {
for _, lineComments := range codeComments { for _, lineComments := range codeComments {
for _, c := range lineComments { for _, c := range lineComments {
if err := c.LoadAttachments(ctx); err != nil {
ctx.ServerError("LoadAttachments", err)
return
}
// Check tag. // Check tag.
role, ok = marked[c.PosterID] role, ok = marked[c.PosterID]
if ok { if ok {

@ -970,6 +970,19 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi
return return
} }
for _, file := range diff.Files {
for _, section := range file.Sections {
for _, line := range section.Lines {
for _, comment := range line.Comments {
if err := comment.LoadAttachments(ctx); err != nil {
ctx.ServerError("LoadAttachments", err)
return
}
}
}
}
}
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch)
if err != nil { if err != nil {
ctx.ServerError("LoadProtectedBranch", err) ctx.ServerError("LoadProtectedBranch", err)

@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/upload"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
@ -50,6 +51,8 @@ func RenderNewCodeCommentForm(ctx *context.Context) {
return return
} }
ctx.Data["AfterCommitID"] = pullHeadCommitID ctx.Data["AfterCommitID"] = pullHeadCommitID
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
ctx.HTML(http.StatusOK, tplNewComment) ctx.HTML(http.StatusOK, tplNewComment)
} }
@ -75,6 +78,11 @@ func CreateCodeComment(ctx *context.Context) {
signedLine *= -1 signedLine *= -1
} }
var attachments []string
if setting.Attachment.Enabled {
attachments = form.Files
}
comment, err := pull_service.CreateCodeComment(ctx, comment, err := pull_service.CreateCodeComment(ctx,
ctx.Doer, ctx.Doer,
ctx.Repo.GitRepo, ctx.Repo.GitRepo,
@ -85,6 +93,7 @@ func CreateCodeComment(ctx *context.Context) {
!form.SingleReview, !form.SingleReview,
form.Reply, form.Reply,
form.LatestCommitID, form.LatestCommitID,
attachments,
) )
if err != nil { if err != nil {
ctx.ServerError("CreateCodeComment", err) ctx.ServerError("CreateCodeComment", err)
@ -168,6 +177,16 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori
return return
} }
for _, c := range comments {
if err := c.LoadAttachments(ctx); err != nil {
ctx.ServerError("LoadAttachments", err)
return
}
}
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
ctx.Data["comments"] = comments ctx.Data["comments"] = comments
if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil { if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil {
ctx.ServerError("CanMarkConversation", err) ctx.ServerError("CanMarkConversation", err)

@ -39,7 +39,7 @@ func TestRenderConversation(t *testing.T) {
var preparedComment *issues_model.Comment var preparedComment *issues_model.Comment
run("prepare", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { run("prepare", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) {
comment, err := pull.CreateCodeComment(ctx, pr.Issue.Poster, ctx.Repo.GitRepo, pr.Issue, 1, "content", "", false, 0, pr.HeadCommitID) comment, err := pull.CreateCodeComment(ctx, pr.Issue.Poster, ctx.Repo.GitRepo, pr.Issue, 1, "content", "", false, 0, pr.HeadCommitID, nil)
if !assert.NoError(t, err) { if !assert.NoError(t, err) {
return return
} }

@ -626,6 +626,7 @@ type CodeCommentForm struct {
SingleReview bool `form:"single_review"` SingleReview bool `form:"single_review"`
Reply int64 `form:"reply"` Reply int64 `form:"reply"`
LatestCommitID string LatestCommitID string
Files []string
} }
// Validate validates the fields // Validate validates the fields

@ -130,6 +130,7 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u
false, // not pending review but a single review false, // not pending review but a single review
comment.ReviewID, comment.ReviewID,
"", "",
nil,
) )
if err != nil { if err != nil {
return fmt.Errorf("CreateCodeComment failed: %w", err) return fmt.Errorf("CreateCodeComment failed: %w", err)

@ -71,7 +71,7 @@ func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestLis
} }
// CreateCodeComment creates a comment on the code line // CreateCodeComment creates a comment on the code line
func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool, replyReviewID int64, latestCommitID string) (*issues_model.Comment, error) { func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool, replyReviewID int64, latestCommitID string, attachments []string) (*issues_model.Comment, error) {
var ( var (
existsReview bool existsReview bool
err error err error
@ -104,6 +104,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
treePath, treePath,
line, line,
replyReviewID, replyReviewID,
attachments,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -144,6 +145,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
treePath, treePath,
line, line,
review.ID, review.ID,
attachments,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -162,7 +164,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
} }
// createCodeComment creates a plain code comment at the specified line / path // createCodeComment creates a plain code comment at the specified line / path
func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64) (*issues_model.Comment, error) { func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64, attachments []string) (*issues_model.Comment, error) {
var commitID, patch string var commitID, patch string
if err := issue.LoadPullRequest(ctx); err != nil { if err := issue.LoadPullRequest(ctx); err != nil {
return nil, fmt.Errorf("LoadPullRequest: %w", err) return nil, fmt.Errorf("LoadPullRequest: %w", err)
@ -260,6 +262,7 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo
ReviewID: reviewID, ReviewID: reviewID,
Patch: patch, Patch: patch,
Invalidated: invalidated, Invalidated: invalidated,
Attachments: attachments,
}) })
} }

@ -237,6 +237,11 @@
"TextareaName" "content" "TextareaName" "content"
"DropzoneParentContainer" ".ui.form" "DropzoneParentContainer" ".ui.form"
)}} )}}
{{if .IsAttachmentEnabled}}
<div class="field">
{{template "repo/upload" .}}
</div>
{{end}}
<div class="text right edit buttons"> <div class="text right edit buttons">
<button class="ui cancel button">{{ctx.Locale.Tr "repo.issues.cancel"}}</button> <button class="ui cancel button">{{ctx.Locale.Tr "repo.issues.cancel"}}</button>
<button class="ui primary save button">{{ctx.Locale.Tr "repo.issues.save"}}</button> <button class="ui primary save button">{{ctx.Locale.Tr "repo.issues.save"}}</button>

@ -19,6 +19,12 @@
"DisableAutosize" "true" "DisableAutosize" "true"
)}} )}}
{{if $.root.IsAttachmentEnabled}}
<div class="field">
{{template "repo/upload" $.root}}
</div>
{{end}}
<div class="field footer gt-mx-3"> <div class="field footer gt-mx-3">
<span class="markup-info">{{svg "octicon-markup"}} {{ctx.Locale.Tr "repo.diff.comment.markdown_info"}}</span> <span class="markup-info">{{svg "octicon-markup"}} {{ctx.Locale.Tr "repo.diff.comment.markdown_info"}}</span>
<div class="gt-text-right"> <div class="gt-text-right">

@ -61,7 +61,10 @@
{{end}} {{end}}
</div> </div>
<div id="issuecomment-{{.ID}}-raw" class="raw-content gt-hidden">{{.Content}}</div> <div id="issuecomment-{{.ID}}-raw" class="raw-content gt-hidden">{{.Content}}</div>
<div class="edit-content-zone gt-hidden" data-update-url="{{$.root.RepoLink}}/comments/{{.ID}}" data-context="{{$.root.RepoLink}}"></div> <div class="edit-content-zone gt-hidden" data-update-url="{{$.root.RepoLink}}/comments/{{.ID}}" data-context="{{$.root.RepoLink}}" data-attachment-url="{{$.root.RepoLink}}/comments/{{.ID}}/attachments"></div>
{{if .Attachments}}
{{template "repo/issue/view_content/attachments" dict "ctxData" $ "Attachments" .Attachments "Content" .RenderedContent}}
{{end}}
</div> </div>
{{$reactions := .Reactions.GroupByType}} {{$reactions := .Reactions.GroupByType}}
{{if $reactions}} {{if $reactions}}

@ -94,6 +94,9 @@
</div> </div>
<div id="issuecomment-{{.ID}}-raw" class="raw-content gt-hidden">{{.Content}}</div> <div id="issuecomment-{{.ID}}-raw" class="raw-content gt-hidden">{{.Content}}</div>
<div class="edit-content-zone gt-hidden" data-update-url="{{$.RepoLink}}/comments/{{.ID}}" data-context="{{$.RepoLink}}" data-attachment-url="{{$.RepoLink}}/comments/{{.ID}}/attachments"></div> <div class="edit-content-zone gt-hidden" data-update-url="{{$.RepoLink}}/comments/{{.ID}}" data-context="{{$.RepoLink}}" data-attachment-url="{{$.RepoLink}}/comments/{{.ID}}/attachments"></div>
{{if .Attachments}}
{{template "repo/issue/view_content/attachments" dict "ctxData" $ "Attachments" .Attachments "Content" .RenderedContent}}
{{end}}
</div> </div>
{{$reactions := .Reactions.GroupByType}} {{$reactions := .Reactions.GroupByType}}
{{if $reactions}} {{if $reactions}}

@ -200,63 +200,66 @@ export function initGlobalCommon() {
} }
export function initGlobalDropzone() { export function initGlobalDropzone() {
// Dropzone
for (const el of document.querySelectorAll('.dropzone')) { for (const el of document.querySelectorAll('.dropzone')) {
const $dropzone = $(el); initDropzone(el);
const _promise = createDropzone(el, { }
url: $dropzone.data('upload-url'), }
headers: {'X-Csrf-Token': csrfToken},
maxFiles: $dropzone.data('max-file'), export function initDropzone(el) {
maxFilesize: $dropzone.data('max-size'), const $dropzone = $(el);
acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), const _promise = createDropzone(el, {
addRemoveLinks: true, url: $dropzone.data('upload-url'),
dictDefaultMessage: $dropzone.data('default-message'), headers: {'X-Csrf-Token': csrfToken},
dictInvalidFileType: $dropzone.data('invalid-input-type'), maxFiles: $dropzone.data('max-file'),
dictFileTooBig: $dropzone.data('file-too-big'), maxFilesize: $dropzone.data('max-size'),
dictRemoveFile: $dropzone.data('remove-file'), acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
timeout: 0, addRemoveLinks: true,
thumbnailMethod: 'contain', dictDefaultMessage: $dropzone.data('default-message'),
thumbnailWidth: 480, dictInvalidFileType: $dropzone.data('invalid-input-type'),
thumbnailHeight: 480, dictFileTooBig: $dropzone.data('file-too-big'),
init() { dictRemoveFile: $dropzone.data('remove-file'),
this.on('success', (file, data) => { timeout: 0,
file.uuid = data.uuid; thumbnailMethod: 'contain',
const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid); thumbnailWidth: 480,
$dropzone.find('.files').append(input); thumbnailHeight: 480,
// Create a "Copy Link" element, to conveniently copy the image init() {
// or file link as Markdown to the clipboard this.on('success', (file, data) => {
const copyLinkElement = document.createElement('div'); file.uuid = data.uuid;
copyLinkElement.className = 'gt-text-center'; const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
// The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone $dropzone.find('.files').append(input);
copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`; // Create a "Copy Link" element, to conveniently copy the image
copyLinkElement.addEventListener('click', async (e) => { // or file link as Markdown to the clipboard
e.preventDefault(); const copyLinkElement = document.createElement('div');
let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; copyLinkElement.className = 'gt-text-center';
if (file.type.startsWith('image/')) { // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone
fileMarkdown = `!${fileMarkdown}`; copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`;
} else if (file.type.startsWith('video/')) { copyLinkElement.addEventListener('click', async (e) => {
fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`; e.preventDefault();
} let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
const success = await clippie(fileMarkdown); if (file.type.startsWith('image/')) {
showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); fileMarkdown = `!${fileMarkdown}`;
}); } else if (file.type.startsWith('video/')) {
file.previewTemplate.append(copyLinkElement); fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`;
});
this.on('removedfile', (file) => {
$(`#${file.uuid}`).remove();
if ($dropzone.data('remove-url')) {
POST($dropzone.data('remove-url'), {
data: new URLSearchParams({file: file.uuid}),
});
} }
const success = await clippie(fileMarkdown);
showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
}); });
this.on('error', function (file, message) { file.previewTemplate.append(copyLinkElement);
showErrorToast(message); });
this.removeFile(file); this.on('removedfile', (file) => {
}); $(`#${file.uuid}`).remove();
}, if ($dropzone.data('remove-url')) {
}); POST($dropzone.data('remove-url'), {
} data: new URLSearchParams({file: file.uuid}),
});
}
});
this.on('error', function (file, message) {
showErrorToast(message);
this.removeFile(file);
});
},
});
} }
async function linkAction(e) { async function linkAction(e) {

@ -5,6 +5,7 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js';
import {setFileFolding} from './file-fold.js'; import {setFileFolding} from './file-fold.js';
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
import {toAbsoluteUrl} from '../utils.js'; import {toAbsoluteUrl} from '../utils.js';
import {initDropzone} from './common-global.js';
const {appSubUrl, csrfToken} = window.config; const {appSubUrl, csrfToken} = window.config;
@ -382,6 +383,11 @@ export async function handleReply($el) {
const $textarea = form.find('textarea'); const $textarea = form.find('textarea');
let editor = getComboMarkdownEditor($textarea); let editor = getComboMarkdownEditor($textarea);
if (!editor) { if (!editor) {
// FIXME: the initialization of the dropzone is not consistent.
// When the page is loaded, the dropzone is initialized by initGlobalDropzone, but the editor is not initialized.
// When the form is submitted and partially reload, none of them is initialized.
const dropzone = form.find('.dropzone')[0];
if (!dropzone.dropzone) initDropzone(dropzone);
editor = await initComboMarkdownEditor(form.find('.combo-markdown-editor')); editor = await initComboMarkdownEditor(form.find('.combo-markdown-editor'));
} }
editor.focus(); editor.focus();
@ -511,6 +517,7 @@ export function initRepoPullRequestReview() {
td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed'); td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
td.find("input[name='path']").val(path); td.find("input[name='path']").val(path);
initDropzone(td.find('.dropzone')[0]);
const editor = await initComboMarkdownEditor(td.find('.combo-markdown-editor')); const editor = await initComboMarkdownEditor(td.find('.combo-markdown-editor'));
editor.focus(); editor.focus();
} }

Loading…
Cancel
Save