]> source.dussan.org Git - gitea.git/commitdiff
Suggestions for issues (#32327)
authorAnbraten <6918444+anbraten@users.noreply.github.com>
Tue, 29 Oct 2024 09:20:49 +0000 (10:20 +0100)
committerGitHub <noreply@github.com>
Tue, 29 Oct 2024 09:20:49 +0000 (17:20 +0800)
closes #16872

package-lock.json
package.json
routers/web/repo/issue_suggestions.go [new file with mode: 0644]
routers/web/web.go
templates/shared/combomarkdowneditor.tmpl
web_src/js/components/ContextPopup.vue
web_src/js/features/comp/TextExpander.ts
web_src/js/features/issue.ts [new file with mode: 0644]
web_src/js/utils/match.ts

index 3da344a23cb513c515151427ca997e48bffe1de0..a2bde7e083834a5831b3e2e18c053f916eb0e746 100644 (file)
@@ -11,7 +11,7 @@
         "@citation-js/plugin-software-formats": "0.6.1",
         "@github/markdown-toolbar-element": "2.2.3",
         "@github/relative-time-element": "4.4.3",
-        "@github/text-expander-element": "2.7.1",
+        "@github/text-expander-element": "2.8.0",
         "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
         "@primer/octicons": "19.11.0",
         "@silverwind/vue3-calendar-heatmap": "2.0.6",
@@ -40,6 +40,7 @@
         "monaco-editor": "0.51.0",
         "monaco-editor-webpack-plugin": "7.1.0",
         "pdfobject": "2.3.0",
+        "perfect-debounce": "1.0.0",
         "postcss": "8.4.41",
         "postcss-loader": "8.1.1",
         "postcss-nesting": "13.0.0",
       "license": "MIT"
     },
     "node_modules/@github/text-expander-element": {
-      "version": "2.7.1",
-      "resolved": "https://registry.npmjs.org/@github/text-expander-element/-/text-expander-element-2.7.1.tgz",
-      "integrity": "sha512-CWxfYxJRkeWVCUhJveproLs6pHsPrWtK8TsjL8ByYVcSCs8CJmNzF8b7ZawrUgfai0F2jb4aIdw2FoBTykj9XA==",
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@github/text-expander-element/-/text-expander-element-2.8.0.tgz",
+      "integrity": "sha512-kkS2rZ/CG8HGKblpLDQ8vcK/K7l/Jsvzi/N4ovwPAsFSOImcIbJh2MgCv9tzqE3wAm/qXlscvh3Ms4Hh1vtZvw==",
       "license": "MIT",
       "dependencies": {
         "@github/combobox-nav": "^2.0.2",
-        "dom-input-range": "^1.1.6"
+        "dom-input-range": "^1.2.0"
       }
     },
     "node_modules/@humanwhocodes/config-array": {
       }
     },
     "node_modules/dom-input-range": {
-      "version": "1.1.6",
-      "resolved": "https://registry.npmjs.org/dom-input-range/-/dom-input-range-1.1.6.tgz",
-      "integrity": "sha512-4o/SkTpscD0n81BeErrrtmE58lG8vTks++92vk//ld0NmkQTb4AVJ2rexh2yor6rtBf5IMte26u+fF3EgCppPQ==",
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/dom-input-range/-/dom-input-range-1.2.0.tgz",
+      "integrity": "sha512-8HVA5Oy5Vt872S7IXsjjp6/5Hqsm5YZLhurxwwQXp80T9qVsj8/mEUH3sQlFujLLUoWfxiaThHHuJ3/q1MHVuA==",
       "license": "MIT",
       "workspaces": [
         "demos"
       "integrity": "sha512-w/9pXDXTDs3IDmOri/w8lM/w6LHR0/F4fcBLLzH+4csSoyshQ5su0TE7k0FLHZO7aOjVLDGecqd1M89+PVpVAA==",
       "license": "MIT"
     },
+    "node_modules/perfect-debounce": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+      "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+      "license": "MIT"
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
index 27b63cb2eb7fe240e9d39b5f88185d45c4291a63..73cb9d3fa54be7b1488292de5660ab1f2f192daa 100644 (file)
@@ -10,7 +10,7 @@
     "@citation-js/plugin-software-formats": "0.6.1",
     "@github/markdown-toolbar-element": "2.2.3",
     "@github/relative-time-element": "4.4.3",
-    "@github/text-expander-element": "2.7.1",
+    "@github/text-expander-element": "2.8.0",
     "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
     "@primer/octicons": "19.11.0",
     "@silverwind/vue3-calendar-heatmap": "2.0.6",
@@ -39,6 +39,7 @@
     "monaco-editor": "0.51.0",
     "monaco-editor-webpack-plugin": "7.1.0",
     "pdfobject": "2.3.0",
+    "perfect-debounce": "1.0.0",
     "postcss": "8.4.41",
     "postcss-loader": "8.1.1",
     "postcss-nesting": "13.0.0",
diff --git a/routers/web/repo/issue_suggestions.go b/routers/web/repo/issue_suggestions.go
new file mode 100644 (file)
index 0000000..361da0e
--- /dev/null
@@ -0,0 +1,93 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+       "net/http"
+
+       "code.gitea.io/gitea/models/db"
+       issues_model "code.gitea.io/gitea/models/issues"
+       "code.gitea.io/gitea/models/unit"
+       issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
+       "code.gitea.io/gitea/modules/optional"
+       "code.gitea.io/gitea/services/context"
+)
+
+type issueSuggestion struct {
+       ID          int64  `json:"id"`
+       Title       string `json:"title"`
+       State       string `json:"state"`
+       PullRequest *struct {
+               Merged bool `json:"merged"`
+               Draft  bool `json:"draft"`
+       } `json:"pull_request,omitempty"`
+}
+
+// IssueSuggestions returns a list of issue suggestions
+func IssueSuggestions(ctx *context.Context) {
+       keyword := ctx.Req.FormValue("q")
+
+       canReadIssues := ctx.Repo.CanRead(unit.TypeIssues)
+       canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests)
+
+       var isPull optional.Option[bool]
+       if canReadPulls && !canReadIssues {
+               isPull = optional.Some(true)
+       } else if canReadIssues && !canReadPulls {
+               isPull = optional.Some(false)
+       }
+
+       searchOpt := &issue_indexer.SearchOptions{
+               Paginator: &db.ListOptions{
+                       Page:     0,
+                       PageSize: 5,
+               },
+               Keyword:  keyword,
+               RepoIDs:  []int64{ctx.Repo.Repository.ID},
+               IsPull:   isPull,
+               IsClosed: nil,
+               SortBy:   issue_indexer.SortByUpdatedDesc,
+       }
+
+       ids, _, err := issue_indexer.SearchIssues(ctx, searchOpt)
+       if err != nil {
+               ctx.ServerError("SearchIssues", err)
+               return
+       }
+       issues, err := issues_model.GetIssuesByIDs(ctx, ids, true)
+       if err != nil {
+               ctx.ServerError("FindIssuesByIDs", err)
+               return
+       }
+
+       suggestions := make([]*issueSuggestion, 0, len(issues))
+
+       for _, issue := range issues {
+               suggestion := &issueSuggestion{
+                       ID:    issue.ID,
+                       Title: issue.Title,
+                       State: string(issue.State()),
+               }
+
+               if issue.IsPull {
+                       if err := issue.LoadPullRequest(ctx); err != nil {
+                               ctx.ServerError("LoadPullRequest", err)
+                               return
+                       }
+                       if issue.PullRequest != nil {
+                               suggestion.PullRequest = &struct {
+                                       Merged bool `json:"merged"`
+                                       Draft  bool `json:"draft"`
+                               }{
+                                       Merged: issue.PullRequest.HasMerged,
+                                       Draft:  issue.PullRequest.IsWorkInProgress(ctx),
+                               }
+                       }
+               }
+
+               suggestions = append(suggestions, suggestion)
+       }
+
+       ctx.JSON(http.StatusOK, suggestions)
+}
index a6ccb7a7924e9a8e82bfc3ba473bf80bed758a2c..83d116babd769032cb72bf79709d47f8e37095fa 100644 (file)
@@ -1178,6 +1178,7 @@ func registerRoutes(m *web.Router) {
                                })
                        })
                }, context.RepoRef())
+               m.Get("/issues/suggestions", repo.IssueSuggestions)
        }, ignSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader)
        // end "/{username}/{reponame}": view milestone, label, issue, pull, etc
 
index a0145ab297b49fdaca2f954f1736cb09d903e40b..0a01dd9b1d7708e79bb5ab99d686aa7ef5442b3b 100644 (file)
@@ -44,7 +44,7 @@ Template Attributes:
                                <button class="markdown-toolbar-button markdown-switch-easymde" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.switch_to_legacy.tooltip"}}">{{svg "octicon-arrow-switch"}}</button>
                        </div>
                </markdown-toolbar>
-               <text-expander keys=": @" suffix="">
+               <text-expander keys=": @ #" multiword="#" suffix="">
                        <textarea class="markdown-text-editor"{{if .TextareaName}} name="{{.TextareaName}}"{{end}}{{if .TextareaPlaceholder}} placeholder="{{.TextareaPlaceholder}}"{{end}}{{if .TextareaAriaLabel}} aria-label="{{.TextareaAriaLabel}}"{{end}}{{if .DisableAutosize}} data-disable-autosize="{{.DisableAutosize}}"{{end}}>{{.TextareaContent}}</textarea>
                </text-expander>
                <script>
index 9fb03dcb7d60c715f3c7baa9185ac0f35969fd10..8c56af858e33f9e21bd54392989d1c2222102651 100644 (file)
@@ -1,8 +1,8 @@
 <script lang="ts" setup>
 import {SvgIcon} from '../svg.ts';
 import {GET} from '../modules/fetch.ts';
+import {getIssueColor, getIssueIcon} from '../features/issue.ts';
 import {computed, onMounted, ref} from 'vue';
-import type {Issue} from '../types';
 
 const {appSubUrl, i18n} = window.config;
 
@@ -21,37 +21,6 @@ const body = computed(() => {
   return body;
 });
 
-function getIssueIcon(issue: Issue) {
-  if (issue.pull_request) {
-    if (issue.state === 'open') {
-      if (issue.pull_request.draft === true) {
-        return 'octicon-git-pull-request-draft'; // WIP PR
-      }
-      return 'octicon-git-pull-request'; // Open PR
-    } else if (issue.pull_request.merged === true) {
-      return 'octicon-git-merge'; // Merged PR
-    }
-    return 'octicon-git-pull-request'; // Closed PR
-  } else if (issue.state === 'open') {
-    return 'octicon-issue-opened'; // Open Issue
-  }
-  return 'octicon-issue-closed'; // Closed Issue
-}
-
-function getIssueColor(issue: Issue) {
-  if (issue.pull_request) {
-    if (issue.pull_request.draft === true) {
-      return 'grey'; // WIP PR
-    } else if (issue.pull_request.merged === true) {
-      return 'purple'; // Merged PR
-    }
-  }
-  if (issue.state === 'open') {
-    return 'green'; // Open Issue
-  }
-  return 'red'; // Closed Issue
-}
-
 const root = ref<HTMLElement | null>(null);
 
 onMounted(() => {
index 5afe025d387909cc2b3887bcdbc00f925d21acab..6ac4c4bf32c19a1c0ae14af198a48ba92df6d762 100644 (file)
@@ -1,5 +1,41 @@
-import {matchEmoji, matchMention} from '../../utils/match.ts';
+import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
 import {emojiString} from '../emoji.ts';
+import {svg} from '../../svg.ts';
+import {parseIssueHref} from '../../utils.ts';
+import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
+import {getIssueColor, getIssueIcon} from '../issue.ts';
+import {debounce} from 'perfect-debounce';
+
+const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => {
+  const {owner, repo, index} = parseIssueHref(window.location.href);
+  const matches = await matchIssue(owner, repo, index, text);
+  if (!matches.length) return resolve({matched: false});
+
+  const ul = document.createElement('ul');
+  ul.classList.add('suggestions');
+  for (const issue of matches) {
+    const li = createElementFromAttrs('li', {
+      role: 'option',
+      'data-value': `${key}${issue.id}`,
+      class: 'tw-flex tw-gap-2',
+    });
+
+    const icon = svg(getIssueIcon(issue), 16, ['text', getIssueColor(issue)].join(' '));
+    li.append(createElementFromHTML(icon));
+
+    const id = document.createElement('span');
+    id.textContent = issue.id.toString();
+    li.append(id);
+
+    const nameSpan = document.createElement('span');
+    nameSpan.textContent = issue.title;
+    li.append(nameSpan);
+
+    ul.append(li);
+  }
+
+  resolve({matched: true, fragment: ul});
+}), 100);
 
 export function initTextExpander(expander) {
   expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
@@ -49,12 +85,14 @@ export function initTextExpander(expander) {
       }
 
       provide({matched: true, fragment: ul});
+    } else if (key === '#') {
+      provide(debouncedSuggestIssues(key, text));
     }
   });
   expander?.addEventListener('text-expander-value', ({detail}) => {
     if (detail?.item) {
-      // add a space after @mentions as it's likely the user wants one
-      const suffix = detail.key === '@' ? ' ' : '';
+      // add a space after @mentions and #issue as it's likely the user wants one
+      const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
       detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
     }
   });
diff --git a/web_src/js/features/issue.ts b/web_src/js/features/issue.ts
new file mode 100644 (file)
index 0000000..a56015a
--- /dev/null
@@ -0,0 +1,32 @@
+import type {Issue} from '../types.ts';
+
+export function getIssueIcon(issue: Issue) {
+  if (issue.pull_request) {
+    if (issue.state === 'open') {
+      if (issue.pull_request.draft === true) {
+        return 'octicon-git-pull-request-draft'; // WIP PR
+      }
+      return 'octicon-git-pull-request'; // Open PR
+    } else if (issue.pull_request.merged === true) {
+      return 'octicon-git-merge'; // Merged PR
+    }
+    return 'octicon-git-pull-request'; // Closed PR
+  } else if (issue.state === 'open') {
+    return 'octicon-issue-opened'; // Open Issue
+  }
+  return 'octicon-issue-closed'; // Closed Issue
+}
+
+export function getIssueColor(issue: Issue) {
+  if (issue.pull_request) {
+    if (issue.pull_request.draft === true) {
+      return 'grey'; // WIP PR
+    } else if (issue.pull_request.merged === true) {
+      return 'purple'; // Merged PR
+    }
+  }
+  if (issue.state === 'open') {
+    return 'green'; // Open Issue
+  }
+  return 'red'; // Closed Issue
+}
index 0ce7e2b1a2a81853184f071ff2a054a1773b071a..2c7271f16eee5a439d65c79fe689a95d8114b86a 100644 (file)
@@ -1,8 +1,10 @@
 import emojis from '../../../assets/emoji.json';
+import type {Issue} from '../features/issue.ts';
+import {GET} from '../modules/fetch.ts';
 
 const maxMatches = 6;
 
-function sortAndReduce(map: Map<string, number>) {
+function sortAndReduce<T>(map: Map<T, number>): T[] {
   const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1]));
   return Array.from(sortedMap.keys()).slice(0, maxMatches);
 }
@@ -27,11 +29,12 @@ export function matchEmoji(queryText: string): string[] {
   return sortAndReduce(results);
 }
 
-export function matchMention(queryText: string): string[] {
+type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string};
+export function matchMention(queryText: string): MentionSuggestion[] {
   const query = queryText.toLowerCase();
 
   // results is a map of weights, lower is better
-  const results = new Map();
+  const results = new Map<MentionSuggestion, number>();
   for (const obj of window.config.mentionValues ?? []) {
     const index = obj.key.toLowerCase().indexOf(query);
     if (index === -1) continue;
@@ -41,3 +44,13 @@ export function matchMention(queryText: string): string[] {
 
   return sortAndReduce(results);
 }
+
+export async function matchIssue(owner: string, repo: string, issueIndexStr: string, query: string): Promise<Issue[]> {
+  const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`);
+
+  const issues: Issue[] = await res.json();
+  const issueIndex = parseInt(issueIndexStr);
+
+  // filter out issue with same id
+  return issues.filter((i) => i.id !== issueIndex);
+}