Skip to content

Suggestions for issues #32327

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Oct 29, 2024
23 changes: 15 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions routers/web/repo/issue_suggestions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package repo

Check failure on line 1 in routers/web/repo/issue_suggestions.go

View workflow job for this annotation

GitHub Actions / lint-backend

Copyright did not match check

Check failure on line 1 in routers/web/repo/issue_suggestions.go

View workflow job for this annotation

GitHub Actions / lint-backend

SPDX-License-Identifier did not match check

Check failure on line 1 in routers/web/repo/issue_suggestions.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

Copyright did not match check

Check failure on line 1 in routers/web/repo/issue_suggestions.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

SPDX-License-Identifier did not match check

import (
"net/http"

"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
"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")

searchOpt := &issue_indexer.SearchOptions{
Paginator: &db.ListOptions{
Page: 0,
PageSize: 5,
},
Keyword: keyword,
RepoIDs: []int64{ctx.Repo.Repository.ID},
IsPull: nil,
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)
}
3 changes: 2 additions & 1 deletion routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -1045,8 +1045,9 @@ func registerRoutes(m *web.Router) {
m.Group("/migrate", func() {
m.Get("/status", repo.MigrateStatus)
})
m.Get("/issues/suggestions", repo.IssueSuggestions)
}, ignSignIn, context.RepoAssignment, reqRepoCodeReader)
// end "/{username}/{reponame}/-": migrate
// end "/{username}/{reponame}/-": migrate, issue suggestions

m.Group("/{username}/{reponame}/settings", func() {
m.Group("", func() {
Expand Down
2 changes: 1 addition & 1 deletion templates/shared/combomarkdowneditor.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
37 changes: 5 additions & 32 deletions web_src/js/components/ContextPopup.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {getIssueColor, getIssueIcon} from '../features/issue.ts';

const {appSubUrl, i18n} = window.config;

Expand All @@ -25,37 +26,6 @@ export default {
}
return body;
},

icon() {
if (this.issue.pull_request !== null) {
if (this.issue.state === 'open') {
if (this.issue.pull_request.draft === true) {
return 'octicon-git-pull-request-draft'; // WIP PR
}
return 'octicon-git-pull-request'; // Open PR
} else if (this.issue.pull_request.merged === true) {
return 'octicon-git-merge'; // Merged PR
}
return 'octicon-git-pull-request'; // Closed PR
} else if (this.issue.state === 'open') {
return 'octicon-issue-opened'; // Open Issue
}
return 'octicon-issue-closed'; // Closed Issue
},

color() {
if (this.issue.pull_request !== null) {
if (this.issue.pull_request.draft === true) {
return 'grey'; // WIP PR
} else if (this.issue.pull_request.merged === true) {
return 'purple'; // Merged PR
}
}
if (this.issue.state === 'open') {
return 'green'; // Open Issue
}
return 'red'; // Closed Issue
},
},
mounted() {
this.$refs.root.addEventListener('ce-load-context-popup', (e) => {
Expand Down Expand Up @@ -85,16 +55,19 @@ export default {
this.loading = false;
}
},
getIssueColor,
getIssueIcon,
},
};
</script>

<template>
<div ref="root">
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
<div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
<div class="flex-text-block">
<svg-icon :name="icon" :class="['text', color]"/>
<svg-icon :name="getIssueColor(issue)" :class="['text', getIssueColor(issue)]"/>
<span class="issue-title tw-font-semibold tw-break-anywhere">
{{ issue.title }}
<span class="index">#{{ issue.number }}</span>
Expand Down
45 changes: 42 additions & 3 deletions web_src/js/features/comp/TextExpander.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
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}`,
});
li.classList.add('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.classList.add('id');
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}}) => {
Expand Down Expand Up @@ -49,12 +86,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}`;
}
});
Expand Down
32 changes: 32 additions & 0 deletions web_src/js/features/issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type Issue = {id: number; title: string; state: 'open' | 'closed'; pull_request?: {draft: boolean; merged: boolean}};

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
}
21 changes: 18 additions & 3 deletions web_src/js/utils/match.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand All @@ -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;
Expand All @@ -41,3 +44,15 @@ export function matchMention(queryText: string): string[] {

return sortAndReduce(results);
}

export async function matchIssue(owner: string, repo: string, _issueIndex: string, queryText: string): Promise<Issue[]> {
const query = queryText.toLowerCase();

const res = await GET(`/${owner}/${repo}/-/issues/suggestions?q=${query}`);

const issues: Issue[] = await res.json();
const issueIndex = parseInt(_issueIndex);

// filter issue with same id
return issues.filter((i) => i.id !== issueIndex);
}
Loading