Fix suggestions for issues (#32380)

This commit is contained in:
wxiaoguang 2024-10-31 04:06:36 +08:00 committed by GitHub
parent f4d3aaeeb9
commit a4a121c684
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 82 additions and 88 deletions

View File

@ -11,19 +11,10 @@ import (
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues" issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/context" "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 // IssueSuggestions returns a list of issue suggestions
func IssueSuggestions(ctx *context.Context) { func IssueSuggestions(ctx *context.Context) {
keyword := ctx.Req.FormValue("q") keyword := ctx.Req.FormValue("q")
@ -61,13 +52,14 @@ func IssueSuggestions(ctx *context.Context) {
return return
} }
suggestions := make([]*issueSuggestion, 0, len(issues)) suggestions := make([]*structs.Issue, 0, len(issues))
for _, issue := range issues { for _, issue := range issues {
suggestion := &issueSuggestion{ suggestion := &structs.Issue{
ID: issue.ID, ID: issue.ID,
Index: issue.Index,
Title: issue.Title, Title: issue.Title,
State: string(issue.State()), State: issue.State(),
} }
if issue.IsPull { if issue.IsPull {
@ -76,12 +68,9 @@ func IssueSuggestions(ctx *context.Context) {
return return
} }
if issue.PullRequest != nil { if issue.PullRequest != nil {
suggestion.PullRequest = &struct { suggestion.PullRequest = &structs.PullRequestMeta{
Merged bool `json:"merged"` HasMerged: issue.PullRequest.HasMerged,
Draft bool `json:"draft"` IsWorkInProgress: issue.PullRequest.IsWorkInProgress(ctx),
}{
Merged: issue.PullRequest.HasMerged,
Draft: issue.PullRequest.IsWorkInProgress(ctx),
} }
} }
} }

View File

@ -3,6 +3,7 @@ import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts'; import {GET} from '../modules/fetch.ts';
import {getIssueColor, getIssueIcon} from '../features/issue.ts'; import {getIssueColor, getIssueIcon} from '../features/issue.ts';
import {computed, onMounted, ref} from 'vue'; import {computed, onMounted, ref} from 'vue';
import type {IssuePathInfo} from '../types.ts';
const {appSubUrl, i18n} = window.config; const {appSubUrl, i18n} = window.config;
@ -25,19 +26,19 @@ const root = ref<HTMLElement | null>(null);
onMounted(() => { onMounted(() => {
root.value.addEventListener('ce-load-context-popup', (e: CustomEvent) => { root.value.addEventListener('ce-load-context-popup', (e: CustomEvent) => {
const data = e.detail; const data: IssuePathInfo = e.detail;
if (!loading.value && issue.value === null) { if (!loading.value && issue.value === null) {
load(data); load(data);
} }
}); });
}); });
async function load(data) { async function load(issuePathInfo: IssuePathInfo) {
loading.value = true; loading.value = true;
i18nErrorMessage.value = null; i18nErrorMessage.value = null;
try { try {
const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`); // backend: GetIssueInfo const response = await GET(`${appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`); // backend: GetIssueInfo
const respJson = await response.json(); const respJson = await response.json();
if (!response.ok) { if (!response.ok) {
i18nErrorMessage.value = respJson.message ?? i18n.network_error; i18nErrorMessage.value = respJson.message ?? i18n.network_error;

View File

@ -1,39 +1,29 @@
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts'; import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts'; import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts'; import {svg} from '../../svg.ts';
import {parseIssueHref} from '../../utils.ts'; import {parseIssueHref, parseIssueNewHref} from '../../utils.ts';
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts'; import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColor, getIssueIcon} from '../issue.ts'; import {getIssueColor, getIssueIcon} from '../issue.ts';
import {debounce} from 'perfect-debounce'; import {debounce} from 'perfect-debounce';
const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => { const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => {
const {owner, repo, index} = parseIssueHref(window.location.href); let issuePathInfo = parseIssueHref(window.location.href);
const matches = await matchIssue(owner, repo, index, text); if (!issuePathInfo.ownerName) issuePathInfo = parseIssueNewHref(window.location.href);
if (!issuePathInfo.ownerName) return resolve({matched: false});
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
if (!matches.length) return resolve({matched: false}); if (!matches.length) return resolve({matched: false});
const ul = document.createElement('ul'); const ul = createElementFromAttrs('ul', {class: 'suggestions'});
ul.classList.add('suggestions');
for (const issue of matches) { for (const issue of matches) {
const li = createElementFromAttrs('li', { const li = createElementFromAttrs(
role: 'option', 'li', {role: 'option', class: 'tw-flex tw-gap-2', 'data-value': `${key}${issue.number}`},
'data-value': `${key}${issue.id}`, createElementFromHTML(svg(getIssueIcon(issue), 16, ['text', getIssueColor(issue)])),
class: 'tw-flex tw-gap-2', createElementFromAttrs('span', null, `#${issue.number}`),
}); createElementFromAttrs('span', null, issue.title),
);
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); ul.append(li);
} }
resolve({matched: true, fragment: ul}); resolve({matched: true, fragment: ul});
}), 100); }), 100);

View File

@ -10,12 +10,10 @@ export function initContextPopups() {
export function attachRefIssueContextPopup(refIssues) { export function attachRefIssueContextPopup(refIssues) {
for (const refIssue of refIssues) { for (const refIssue of refIssues) {
if (refIssue.classList.contains('ref-external-issue')) { if (refIssue.classList.contains('ref-external-issue')) continue;
return;
}
const {owner, repo, index} = parseIssueHref(refIssue.getAttribute('href')); const issuePathInfo = parseIssueHref(refIssue.getAttribute('href'));
if (!owner) return; if (!issuePathInfo.ownerName) continue;
const el = document.createElement('div'); const el = document.createElement('div');
el.classList.add('tw-p-3'); el.classList.add('tw-p-3');
@ -38,7 +36,7 @@ export function attachRefIssueContextPopup(refIssues) {
role: 'dialog', role: 'dialog',
interactiveBorder: 5, interactiveBorder: 5,
onShow: () => { onShow: () => {
el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}})); el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: issuePathInfo}));
}, },
}); });
} }

View File

@ -153,7 +153,8 @@ export type SvgName = keyof typeof svgs;
// most of the SVG icons in assets couldn't be used directly. // most of the SVG icons in assets couldn't be used directly.
// retrieve an HTML string for given SVG icon name, size and additional classes // retrieve an HTML string for given SVG icon name, size and additional classes
export function svg(name: SvgName, size = 16, className = '') { export function svg(name: SvgName, size = 16, classNames: string|string[]): string {
const className = Array.isArray(classNames) ? classNames.join(' ') : classNames;
if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`); if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`);
if (size === 16 && !className) return svgs[name]; if (size === 16 && !className) return svgs[name];

View File

@ -30,15 +30,16 @@ export type RequestOpts = {
data?: RequestData, data?: RequestData,
} & RequestInit; } & RequestInit;
export type IssueData = { export type IssuePathInfo = {
owner: string, ownerName: string,
repo: string, repoName: string,
type: string, pathType: string,
index: string, indexString?: string,
} }
export type Issue = { export type Issue = {
id: number; id: number;
number: number;
title: string; title: string;
state: 'open' | 'closed'; state: 'open' | 'closed';
pull_request?: { pull_request?: {

View File

@ -1,7 +1,7 @@
import { import {
basename, extname, isObject, stripTags, parseIssueHref, basename, extname, isObject, stripTags, parseIssueHref,
parseUrl, translateMonth, translateDay, blobToDataURI, parseUrl, translateMonth, translateDay, blobToDataURI,
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseIssueNewHref,
} from './utils.ts'; } from './utils.ts';
test('basename', () => { test('basename', () => {
@ -28,21 +28,27 @@ test('stripTags', () => {
}); });
test('parseIssueHref', () => { test('parseIssueHref', () => {
expect(parseIssueHref('/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'}); expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
expect(parseIssueHref('/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('/owner/repo/issues/1#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('/sub/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('/sub/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('/sub/sub2/owner/repo/pulls/1')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'}); expect(parseIssueHref('/sub/sub2/owner/repo/pulls/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
expect(parseIssueHref('/sub/sub2/owner/repo/issues/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('/sub/sub2/owner/repo/issues/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('/sub/sub2/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('/sub/sub2/owner/repo/issues/1#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('https://example.com/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('https://example.com/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('https://example.com/owner/repo/pulls/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'}); expect(parseIssueHref('https://example.com/owner/repo/pulls/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
expect(parseIssueHref('https://example.com/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('https://example.com/owner/repo/issues/1#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('https://example.com/sub/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('https://example.com/sub/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/pulls/1')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'}); expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/pulls/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'}); expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined}); expect(parseIssueHref('')).toEqual({ownerName: undefined, repoName: undefined, type: undefined, index: undefined});
});
test('parseIssueNewHref', () => {
expect(parseIssueNewHref('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
expect(parseIssueNewHref('/owner/repo/issues/new?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
expect(parseIssueNewHref('/sub/owner/repo/issues/new#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
}); });
test('parseUrl', () => { test('parseUrl', () => {

View File

@ -1,5 +1,5 @@
import {encode, decode} from 'uint8-to-base64'; import {encode, decode} from 'uint8-to-base64';
import type {IssueData} from './types.ts'; import type {IssuePathInfo} from './types.ts';
// transform /path/to/file.ext to file.ext // transform /path/to/file.ext to file.ext
export function basename(path: string): string { export function basename(path: string): string {
@ -31,10 +31,16 @@ export function stripTags(text: string): string {
return text.replace(/<[^>]*>?/g, ''); return text.replace(/<[^>]*>?/g, '');
} }
export function parseIssueHref(href: string): IssueData { export function parseIssueHref(href: string): IssuePathInfo {
const path = (href || '').replace(/[#?].*$/, ''); const path = (href || '').replace(/[#?].*$/, '');
const [_, owner, repo, type, index] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || []; const [_, ownerName, repoName, pathType, indexString] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || [];
return {owner, repo, type, index}; return {ownerName, repoName, pathType, indexString};
}
export function parseIssueNewHref(href: string): IssuePathInfo {
const path = (href || '').replace(/[#?].*$/, '');
const [_, ownerName, repoName, pathType, indexString] = /([^/]+)\/([^/]+)\/(issues|pulls)\/new/.exec(path) || [];
return {ownerName, repoName, pathType, indexString};
} }
// parse a URL, either relative '/path' or absolute 'https://localhost/path' // parse a URL, either relative '/path' or absolute 'https://localhost/path'

View File

@ -8,11 +8,11 @@ test('createElementFromAttrs', () => {
const el = createElementFromAttrs('button', { const el = createElementFromAttrs('button', {
id: 'the-id', id: 'the-id',
class: 'cls-1 cls-2', class: 'cls-1 cls-2',
'data-foo': 'the-data',
disabled: true, disabled: true,
checked: false, checked: false,
required: null, required: null,
tabindex: 0, tabindex: 0,
}); 'data-foo': 'the-data',
expect(el.outerHTML).toEqual('<button id="the-id" class="cls-1 cls-2" data-foo="the-data" disabled="" tabindex="0"></button>'); }, 'txt', createElementFromHTML('<span>inner</span>'));
expect(el.outerHTML).toEqual('<button id="the-id" class="cls-1 cls-2" disabled="" tabindex="0" data-foo="the-data">txt<span>inner</span></button>');
}); });

View File

@ -298,22 +298,24 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st
} }
// Warning: Do not enter any unsanitized variables here // Warning: Do not enter any unsanitized variables here
export function createElementFromHTML(htmlString: string) { export function createElementFromHTML(htmlString: string): HTMLElement {
const div = document.createElement('div'); const div = document.createElement('div');
div.innerHTML = htmlString.trim(); div.innerHTML = htmlString.trim();
return div.firstChild as Element; return div.firstChild as HTMLElement;
} }
export function createElementFromAttrs(tagName: string, attrs: Record<string, any>) { export function createElementFromAttrs(tagName: string, attrs: Record<string, any>, ...children: (Node|string)[]): HTMLElement {
const el = document.createElement(tagName); const el = document.createElement(tagName);
for (const [key, value] of Object.entries(attrs)) { for (const [key, value] of Object.entries(attrs || {})) {
if (value === undefined || value === null) continue; if (value === undefined || value === null) continue;
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
el.toggleAttribute(key, value); el.toggleAttribute(key, value);
} else { } else {
el.setAttribute(key, String(value)); el.setAttribute(key, String(value));
} }
// TODO: in the future we could make it also support "textContent" and "innerHTML" properties if needed }
for (const child of children) {
el.append(child instanceof Node ? child : document.createTextNode(child));
} }
return el; return el;
} }

View File

@ -1,6 +1,6 @@
import emojis from '../../../assets/emoji.json'; import emojis from '../../../assets/emoji.json';
import type {Issue} from '../features/issue.ts';
import {GET} from '../modules/fetch.ts'; import {GET} from '../modules/fetch.ts';
import type {Issue} from '../features/issue.ts';
const maxMatches = 6; const maxMatches = 6;
@ -49,8 +49,8 @@ export async function matchIssue(owner: string, repo: string, issueIndexStr: str
const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`); const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`);
const issues: Issue[] = await res.json(); const issues: Issue[] = await res.json();
const issueIndex = parseInt(issueIndexStr); const issueNumber = parseInt(issueIndexStr);
// filter out issue with same id // filter out issue with same id
return issues.filter((i) => i.id !== issueIndex); return issues.filter((i) => i.number !== issueNumber);
} }