Skip to content

Commit b7fb20e

Browse files
authored
Suggestions for issues (#32327)
closes #16872
1 parent 348d1d0 commit b7fb20e

File tree

9 files changed

+202
-48
lines changed

9 files changed

+202
-48
lines changed

package-lock.json

+15-8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"@citation-js/plugin-software-formats": "0.6.1",
1111
"@github/markdown-toolbar-element": "2.2.3",
1212
"@github/relative-time-element": "4.4.3",
13-
"@github/text-expander-element": "2.7.1",
13+
"@github/text-expander-element": "2.8.0",
1414
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
1515
"@primer/octicons": "19.11.0",
1616
"@silverwind/vue3-calendar-heatmap": "2.0.6",
@@ -39,6 +39,7 @@
3939
"monaco-editor": "0.51.0",
4040
"monaco-editor-webpack-plugin": "7.1.0",
4141
"pdfobject": "2.3.0",
42+
"perfect-debounce": "1.0.0",
4243
"postcss": "8.4.41",
4344
"postcss-loader": "8.1.1",
4445
"postcss-nesting": "13.0.0",

routers/web/repo/issue_suggestions.go

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repo
5+
6+
import (
7+
"net/http"
8+
9+
"code.gitea.io/gitea/models/db"
10+
issues_model "code.gitea.io/gitea/models/issues"
11+
"code.gitea.io/gitea/models/unit"
12+
issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
13+
"code.gitea.io/gitea/modules/optional"
14+
"code.gitea.io/gitea/services/context"
15+
)
16+
17+
type issueSuggestion struct {
18+
ID int64 `json:"id"`
19+
Title string `json:"title"`
20+
State string `json:"state"`
21+
PullRequest *struct {
22+
Merged bool `json:"merged"`
23+
Draft bool `json:"draft"`
24+
} `json:"pull_request,omitempty"`
25+
}
26+
27+
// IssueSuggestions returns a list of issue suggestions
28+
func IssueSuggestions(ctx *context.Context) {
29+
keyword := ctx.Req.FormValue("q")
30+
31+
canReadIssues := ctx.Repo.CanRead(unit.TypeIssues)
32+
canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests)
33+
34+
var isPull optional.Option[bool]
35+
if canReadPulls && !canReadIssues {
36+
isPull = optional.Some(true)
37+
} else if canReadIssues && !canReadPulls {
38+
isPull = optional.Some(false)
39+
}
40+
41+
searchOpt := &issue_indexer.SearchOptions{
42+
Paginator: &db.ListOptions{
43+
Page: 0,
44+
PageSize: 5,
45+
},
46+
Keyword: keyword,
47+
RepoIDs: []int64{ctx.Repo.Repository.ID},
48+
IsPull: isPull,
49+
IsClosed: nil,
50+
SortBy: issue_indexer.SortByUpdatedDesc,
51+
}
52+
53+
ids, _, err := issue_indexer.SearchIssues(ctx, searchOpt)
54+
if err != nil {
55+
ctx.ServerError("SearchIssues", err)
56+
return
57+
}
58+
issues, err := issues_model.GetIssuesByIDs(ctx, ids, true)
59+
if err != nil {
60+
ctx.ServerError("FindIssuesByIDs", err)
61+
return
62+
}
63+
64+
suggestions := make([]*issueSuggestion, 0, len(issues))
65+
66+
for _, issue := range issues {
67+
suggestion := &issueSuggestion{
68+
ID: issue.ID,
69+
Title: issue.Title,
70+
State: string(issue.State()),
71+
}
72+
73+
if issue.IsPull {
74+
if err := issue.LoadPullRequest(ctx); err != nil {
75+
ctx.ServerError("LoadPullRequest", err)
76+
return
77+
}
78+
if issue.PullRequest != nil {
79+
suggestion.PullRequest = &struct {
80+
Merged bool `json:"merged"`
81+
Draft bool `json:"draft"`
82+
}{
83+
Merged: issue.PullRequest.HasMerged,
84+
Draft: issue.PullRequest.IsWorkInProgress(ctx),
85+
}
86+
}
87+
}
88+
89+
suggestions = append(suggestions, suggestion)
90+
}
91+
92+
ctx.JSON(http.StatusOK, suggestions)
93+
}

routers/web/web.go

+1
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,7 @@ func registerRoutes(m *web.Router) {
11781178
})
11791179
})
11801180
}, context.RepoRef())
1181+
m.Get("/issues/suggestions", repo.IssueSuggestions)
11811182
}, ignSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader)
11821183
// end "/{username}/{reponame}": view milestone, label, issue, pull, etc
11831184

templates/shared/combomarkdowneditor.tmpl

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Template Attributes:
4444
<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>
4545
</div>
4646
</markdown-toolbar>
47-
<text-expander keys=": @" suffix="">
47+
<text-expander keys=": @ #" multiword="#" suffix="">
4848
<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>
4949
</text-expander>
5050
<script>

web_src/js/components/ContextPopup.vue

+1-32
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<script lang="ts" setup>
22
import {SvgIcon} from '../svg.ts';
33
import {GET} from '../modules/fetch.ts';
4+
import {getIssueColor, getIssueIcon} from '../features/issue.ts';
45
import {computed, onMounted, ref} from 'vue';
5-
import type {Issue} from '../types';
66
77
const {appSubUrl, i18n} = window.config;
88
@@ -21,37 +21,6 @@ const body = computed(() => {
2121
return body;
2222
});
2323
24-
function getIssueIcon(issue: Issue) {
25-
if (issue.pull_request) {
26-
if (issue.state === 'open') {
27-
if (issue.pull_request.draft === true) {
28-
return 'octicon-git-pull-request-draft'; // WIP PR
29-
}
30-
return 'octicon-git-pull-request'; // Open PR
31-
} else if (issue.pull_request.merged === true) {
32-
return 'octicon-git-merge'; // Merged PR
33-
}
34-
return 'octicon-git-pull-request'; // Closed PR
35-
} else if (issue.state === 'open') {
36-
return 'octicon-issue-opened'; // Open Issue
37-
}
38-
return 'octicon-issue-closed'; // Closed Issue
39-
}
40-
41-
function getIssueColor(issue: Issue) {
42-
if (issue.pull_request) {
43-
if (issue.pull_request.draft === true) {
44-
return 'grey'; // WIP PR
45-
} else if (issue.pull_request.merged === true) {
46-
return 'purple'; // Merged PR
47-
}
48-
}
49-
if (issue.state === 'open') {
50-
return 'green'; // Open Issue
51-
}
52-
return 'red'; // Closed Issue
53-
}
54-
5524
const root = ref<HTMLElement | null>(null);
5625
5726
onMounted(() => {

web_src/js/features/comp/TextExpander.ts

+41-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
1-
import {matchEmoji, matchMention} from '../../utils/match.ts';
1+
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
22
import {emojiString} from '../emoji.ts';
3+
import {svg} from '../../svg.ts';
4+
import {parseIssueHref} from '../../utils.ts';
5+
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
6+
import {getIssueColor, getIssueIcon} from '../issue.ts';
7+
import {debounce} from 'perfect-debounce';
8+
9+
const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => {
10+
const {owner, repo, index} = parseIssueHref(window.location.href);
11+
const matches = await matchIssue(owner, repo, index, text);
12+
if (!matches.length) return resolve({matched: false});
13+
14+
const ul = document.createElement('ul');
15+
ul.classList.add('suggestions');
16+
for (const issue of matches) {
17+
const li = createElementFromAttrs('li', {
18+
role: 'option',
19+
'data-value': `${key}${issue.id}`,
20+
class: 'tw-flex tw-gap-2',
21+
});
22+
23+
const icon = svg(getIssueIcon(issue), 16, ['text', getIssueColor(issue)].join(' '));
24+
li.append(createElementFromHTML(icon));
25+
26+
const id = document.createElement('span');
27+
id.textContent = issue.id.toString();
28+
li.append(id);
29+
30+
const nameSpan = document.createElement('span');
31+
nameSpan.textContent = issue.title;
32+
li.append(nameSpan);
33+
34+
ul.append(li);
35+
}
36+
37+
resolve({matched: true, fragment: ul});
38+
}), 100);
339

440
export function initTextExpander(expander) {
541
expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
@@ -49,12 +85,14 @@ export function initTextExpander(expander) {
4985
}
5086

5187
provide({matched: true, fragment: ul});
88+
} else if (key === '#') {
89+
provide(debouncedSuggestIssues(key, text));
5290
}
5391
});
5492
expander?.addEventListener('text-expander-value', ({detail}) => {
5593
if (detail?.item) {
56-
// add a space after @mentions as it's likely the user wants one
57-
const suffix = detail.key === '@' ? ' ' : '';
94+
// add a space after @mentions and #issue as it's likely the user wants one
95+
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
5896
detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
5997
}
6098
});

web_src/js/features/issue.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type {Issue} from '../types.ts';
2+
3+
export function getIssueIcon(issue: Issue) {
4+
if (issue.pull_request) {
5+
if (issue.state === 'open') {
6+
if (issue.pull_request.draft === true) {
7+
return 'octicon-git-pull-request-draft'; // WIP PR
8+
}
9+
return 'octicon-git-pull-request'; // Open PR
10+
} else if (issue.pull_request.merged === true) {
11+
return 'octicon-git-merge'; // Merged PR
12+
}
13+
return 'octicon-git-pull-request'; // Closed PR
14+
} else if (issue.state === 'open') {
15+
return 'octicon-issue-opened'; // Open Issue
16+
}
17+
return 'octicon-issue-closed'; // Closed Issue
18+
}
19+
20+
export function getIssueColor(issue: Issue) {
21+
if (issue.pull_request) {
22+
if (issue.pull_request.draft === true) {
23+
return 'grey'; // WIP PR
24+
} else if (issue.pull_request.merged === true) {
25+
return 'purple'; // Merged PR
26+
}
27+
}
28+
if (issue.state === 'open') {
29+
return 'green'; // Open Issue
30+
}
31+
return 'red'; // Closed Issue
32+
}

web_src/js/utils/match.ts

+16-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import emojis from '../../../assets/emoji.json';
2+
import type {Issue} from '../features/issue.ts';
3+
import {GET} from '../modules/fetch.ts';
24

35
const maxMatches = 6;
46

5-
function sortAndReduce(map: Map<string, number>) {
7+
function sortAndReduce<T>(map: Map<T, number>): T[] {
68
const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1]));
79
return Array.from(sortedMap.keys()).slice(0, maxMatches);
810
}
@@ -27,11 +29,12 @@ export function matchEmoji(queryText: string): string[] {
2729
return sortAndReduce(results);
2830
}
2931

30-
export function matchMention(queryText: string): string[] {
32+
type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string};
33+
export function matchMention(queryText: string): MentionSuggestion[] {
3134
const query = queryText.toLowerCase();
3235

3336
// results is a map of weights, lower is better
34-
const results = new Map();
37+
const results = new Map<MentionSuggestion, number>();
3538
for (const obj of window.config.mentionValues ?? []) {
3639
const index = obj.key.toLowerCase().indexOf(query);
3740
if (index === -1) continue;
@@ -41,3 +44,13 @@ export function matchMention(queryText: string): string[] {
4144

4245
return sortAndReduce(results);
4346
}
47+
48+
export async function matchIssue(owner: string, repo: string, issueIndexStr: string, query: string): Promise<Issue[]> {
49+
const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`);
50+
51+
const issues: Issue[] = await res.json();
52+
const issueIndex = parseInt(issueIndexStr);
53+
54+
// filter out issue with same id
55+
return issues.filter((i) => i.id !== issueIndex);
56+
}

0 commit comments

Comments
 (0)