]> source.dussan.org Git - gitea.git/commitdiff
Add attachment support for code review comments (#29220)
authorJimmy Praet <jimmy.praet@telenet.be>
Sun, 25 Feb 2024 06:00:55 +0000 (07:00 +0100)
committerGitHub <noreply@github.com>
Sun, 25 Feb 2024 06:00:55 +0000 (06:00 +0000)
Fixes #27960, #24411, #12183

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
15 files changed:
models/issues/comment.go
routers/api/v1/repo/pull_review.go
routers/web/repo/issue.go
routers/web/repo/pull.go
routers/web/repo/pull_review.go
routers/web/repo/pull_review_test.go
services/forms/repo_form.go
services/mailer/incoming/incoming_handler.go
services/pull/review.go
templates/repo/diff/box.tmpl
templates/repo/diff/comment_form.tmpl
templates/repo/diff/comments.tmpl
templates/repo/issue/view_content/conversation.tmpl
web_src/js/features/common-global.js
web_src/js/features/repo-issue.js

index fa0eb3cc0f1d37860752644a143b8d50cdb4c5df..c7b22f3cca073b2fa367af5975e4466af582949b 100644 (file)
@@ -855,6 +855,9 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
        // Check comment type.
        switch opts.Type {
        case CommentTypeCode:
+               if err = updateAttachments(ctx, opts, comment); err != nil {
+                       return err
+               }
                if comment.ReviewID != 0 {
                        if comment.Review == nil {
                                if err := comment.loadReview(ctx); err != nil {
@@ -872,22 +875,9 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
                }
                fallthrough
        case CommentTypeReview:
-               // Check attachments
-               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)
-                       }
+               if err = updateAttachments(ctx, opts, comment); err != nil {
+                       return err
                }
-
-               comment.Attachments = attachments
        case CommentTypeReopen, CommentTypeClose:
                if err = repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.Issue.IsPull, true); err != nil {
                        return err
@@ -897,6 +887,23 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
        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) {
        var content string
        var commentType CommentType
index 07d8f4877bb3de1f21fc3039f09cc05ed6399c13..6338651aae9921f85a1fc94479aa606c9845bfdb 100644 (file)
@@ -362,6 +362,7 @@ func CreatePullReview(ctx *context.APIContext) {
                        true, // pending review
                        0,    // no reply
                        opts.CommitID,
+                       nil,
                ); err != nil {
                        ctx.Error(http.StatusInternalServerError, "CreateCodeComment", err)
                        return
index 245ed2b2f2dd744ce821f93ef7642dfd7a245821..46d48c46381a8fb9a4c246cd8d0495200ad04b45 100644 (file)
@@ -1718,6 +1718,10 @@ func ViewIssue(ctx *context.Context) {
                        for _, codeComments := range comment.Review.CodeComments {
                                for _, lineComments := range codeComments {
                                        for _, c := range lineComments {
+                                               if err := c.LoadAttachments(ctx); err != nil {
+                                                       ctx.ServerError("LoadAttachments", err)
+                                                       return
+                                               }
                                                // Check tag.
                                                role, ok = marked[c.PosterID]
                                                if ok {
index 7ab21f22b9e389b9e04d4b294af22deb88a82b9a..af626dad303d3b09ca460bda6596389422e60854 100644 (file)
@@ -970,6 +970,19 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi
                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)
        if err != nil {
                ctx.ServerError("LoadProtectedBranch", err)
index f84510b39d85d3a2bd5cccf1b84ce83f96f9394c..92665af7e78bfe3f338a776300c109296904b030 100644 (file)
@@ -15,6 +15,7 @@ import (
        "code.gitea.io/gitea/modules/json"
        "code.gitea.io/gitea/modules/log"
        "code.gitea.io/gitea/modules/setting"
+       "code.gitea.io/gitea/modules/upload"
        "code.gitea.io/gitea/modules/web"
        "code.gitea.io/gitea/services/forms"
        pull_service "code.gitea.io/gitea/services/pull"
@@ -50,6 +51,8 @@ func RenderNewCodeCommentForm(ctx *context.Context) {
                return
        }
        ctx.Data["AfterCommitID"] = pullHeadCommitID
+       ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
+       upload.AddUploadContext(ctx, "comment")
        ctx.HTML(http.StatusOK, tplNewComment)
 }
 
@@ -75,6 +78,11 @@ func CreateCodeComment(ctx *context.Context) {
                signedLine *= -1
        }
 
+       var attachments []string
+       if setting.Attachment.Enabled {
+               attachments = form.Files
+       }
+
        comment, err := pull_service.CreateCodeComment(ctx,
                ctx.Doer,
                ctx.Repo.GitRepo,
@@ -85,6 +93,7 @@ func CreateCodeComment(ctx *context.Context) {
                !form.SingleReview,
                form.Reply,
                form.LatestCommitID,
+               attachments,
        )
        if err != nil {
                ctx.ServerError("CreateCodeComment", err)
@@ -168,6 +177,16 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori
                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
        if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil {
                ctx.ServerError("CanMarkConversation", err)
index 7e6594774a8488be9538cb4d74cc7cb828c15be0..8fc9cecaf35bf79027e755e4e7121ebdceedcfed 100644 (file)
@@ -39,7 +39,7 @@ func TestRenderConversation(t *testing.T) {
 
        var preparedComment *issues_model.Comment
        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) {
                        return
                }
index 98d556b946181705ac56bfeb62167007cb11b7b0..98b8d610d0abd07eb22a65f5d3b47f8c3ec26df4 100644 (file)
@@ -626,6 +626,7 @@ type CodeCommentForm struct {
        SingleReview   bool   `form:"single_review"`
        Reply          int64  `form:"reply"`
        LatestCommitID string
+       Files          []string
 }
 
 // Validate validates the fields
index 9682c52456d12456e5da88aa6dc33d06eb39bc6e..5ce2cd5fd578a339bdd707afb7b70094caf9cd81 100644 (file)
@@ -130,6 +130,7 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u
                                false, // not pending review but a single review
                                comment.ReviewID,
                                "",
+                               nil,
                        )
                        if err != nil {
                                return fmt.Errorf("CreateCodeComment failed: %w", err)
index d4ea97561253ebc85ab4515b7cb42c898a1dd0e9..3ffc276778c96e3cfe482740c2a6d3c45b23e8bc 100644 (file)
@@ -71,7 +71,7 @@ func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestLis
 }
 
 // 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 (
                existsReview bool
                err          error
@@ -104,6 +104,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
                        treePath,
                        line,
                        replyReviewID,
+                       attachments,
                )
                if err != nil {
                        return nil, err
@@ -144,6 +145,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
                treePath,
                line,
                review.ID,
+               attachments,
        )
        if err != nil {
                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
-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
        if err := issue.LoadPullRequest(ctx); err != nil {
                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,
                Patch:       patch,
                Invalidated: invalidated,
+               Attachments: attachments,
        })
 }
 
index abeeacead03536db4f37fc9877e81a0adcfbf54d..b9a43a06127f8030de04e2cb974c2389740c2834 100644 (file)
                                        "TextareaName" "content"
                                        "DropzoneParentContainer" ".ui.form"
                                )}}
+                               {{if .IsAttachmentEnabled}}
+                                       <div class="field">
+                                               {{template "repo/upload" .}}
+                                       </div>
+                               {{end}}
                                <div class="text right edit buttons">
                                        <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>
index 767c2613a06431939a1a616e5e8e7d315068bc7f..54817d47401837d24313fd16b7d77047af8a0e86 100644 (file)
                        "DisableAutosize" "true"
                )}}
 
+               {{if $.root.IsAttachmentEnabled}}
+                       <div class="field">
+                               {{template "repo/upload" $.root}}
+                       </div>
+               {{end}}
+
                <div class="field footer gt-mx-3">
                        <span class="markup-info">{{svg "octicon-markup"}} {{ctx.Locale.Tr "repo.diff.comment.markdown_info"}}</span>
                        <div class="gt-text-right">
index b3d06ed6bceccca3cb73a72850e27db3663ec6af..b795074e4985279ee3eb5cb57a5993a091f30698 100644 (file)
                        {{end}}
                        </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>
                {{$reactions := .Reactions.GroupByType}}
                {{if $reactions}}
index 1bc850d8cfe72eba9d4bdcfa71adfabfd8aa8737..56f1af19b2b1a4507621def25cb64800b8e3fc51 100644 (file)
@@ -94,6 +94,9 @@
                                                        </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>
+                                                       {{if .Attachments}}
+                                                               {{template "repo/issue/view_content/attachments" dict "ctxData" $ "Attachments" .Attachments "Content" .RenderedContent}}
+                                                       {{end}}
                                                </div>
                                                {{$reactions := .Reactions.GroupByType}}
                                                {{if $reactions}}
index e8b546970fcbcf2f3d07851c9465520ee036500a..cd0fc6d6a9280851254a0505214db0eb7d7c7445 100644 (file)
@@ -200,63 +200,66 @@ export function initGlobalCommon() {
 }
 
 export function initGlobalDropzone() {
-  // Dropzone
   for (const el of document.querySelectorAll('.dropzone')) {
-    const $dropzone = $(el);
-    const _promise = createDropzone(el, {
-      url: $dropzone.data('upload-url'),
-      headers: {'X-Csrf-Token': csrfToken},
-      maxFiles: $dropzone.data('max-file'),
-      maxFilesize: $dropzone.data('max-size'),
-      acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
-      addRemoveLinks: true,
-      dictDefaultMessage: $dropzone.data('default-message'),
-      dictInvalidFileType: $dropzone.data('invalid-input-type'),
-      dictFileTooBig: $dropzone.data('file-too-big'),
-      dictRemoveFile: $dropzone.data('remove-file'),
-      timeout: 0,
-      thumbnailMethod: 'contain',
-      thumbnailWidth: 480,
-      thumbnailHeight: 480,
-      init() {
-        this.on('success', (file, data) => {
-          file.uuid = data.uuid;
-          const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
-          $dropzone.find('.files').append(input);
-          // Create a "Copy Link" element, to conveniently copy the image
-          // or file link as Markdown to the clipboard
-          const copyLinkElement = document.createElement('div');
-          copyLinkElement.className = 'gt-text-center';
-          // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone
-          copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`;
-          copyLinkElement.addEventListener('click', async (e) => {
-            e.preventDefault();
-            let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
-            if (file.type.startsWith('image/')) {
-              fileMarkdown = `!${fileMarkdown}`;
-            } else if (file.type.startsWith('video/')) {
-              fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`;
-            }
-            const success = await clippie(fileMarkdown);
-            showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
-          });
-          file.previewTemplate.append(copyLinkElement);
-        });
-        this.on('removedfile', (file) => {
-          $(`#${file.uuid}`).remove();
-          if ($dropzone.data('remove-url')) {
-            POST($dropzone.data('remove-url'), {
-              data: new URLSearchParams({file: file.uuid}),
-            });
+    initDropzone(el);
+  }
+}
+
+export function initDropzone(el) {
+  const $dropzone = $(el);
+  const _promise = createDropzone(el, {
+    url: $dropzone.data('upload-url'),
+    headers: {'X-Csrf-Token': csrfToken},
+    maxFiles: $dropzone.data('max-file'),
+    maxFilesize: $dropzone.data('max-size'),
+    acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
+    addRemoveLinks: true,
+    dictDefaultMessage: $dropzone.data('default-message'),
+    dictInvalidFileType: $dropzone.data('invalid-input-type'),
+    dictFileTooBig: $dropzone.data('file-too-big'),
+    dictRemoveFile: $dropzone.data('remove-file'),
+    timeout: 0,
+    thumbnailMethod: 'contain',
+    thumbnailWidth: 480,
+    thumbnailHeight: 480,
+    init() {
+      this.on('success', (file, data) => {
+        file.uuid = data.uuid;
+        const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
+        $dropzone.find('.files').append(input);
+        // Create a "Copy Link" element, to conveniently copy the image
+        // or file link as Markdown to the clipboard
+        const copyLinkElement = document.createElement('div');
+        copyLinkElement.className = 'gt-text-center';
+        // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone
+        copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`;
+        copyLinkElement.addEventListener('click', async (e) => {
+          e.preventDefault();
+          let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
+          if (file.type.startsWith('image/')) {
+            fileMarkdown = `!${fileMarkdown}`;
+          } else if (file.type.startsWith('video/')) {
+            fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`;
           }
+          const success = await clippie(fileMarkdown);
+          showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
         });
-        this.on('error', function (file, message) {
-          showErrorToast(message);
-          this.removeFile(file);
-        });
-      },
-    });
-  }
+        file.previewTemplate.append(copyLinkElement);
+      });
+      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) {
index 3437565c804ae55b6f9f13a95564469b1580a34e..10faeb135d13cacdc1eb69aa4b997e2bc23f38a2 100644 (file)
@@ -5,6 +5,7 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js';
 import {setFileFolding} from './file-fold.js';
 import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 import {toAbsoluteUrl} from '../utils.js';
+import {initDropzone} from './common-global.js';
 
 const {appSubUrl, csrfToken} = window.config;
 
@@ -382,6 +383,11 @@ export async function handleReply($el) {
   const $textarea = form.find('textarea');
   let editor = getComboMarkdownEditor($textarea);
   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.focus();
@@ -511,6 +517,7 @@ export function initRepoPullRequestReview() {
       td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
       td.find("input[name='path']").val(path);
 
+      initDropzone(td.find('.dropzone')[0]);
       const editor = await initComboMarkdownEditor(td.find('.combo-markdown-editor'));
       editor.focus();
     }