mirror of
https://github.com/go-gitea/gitea.git
synced 2025-12-20 11:18:36 +00:00
Automatic generation of release notes (#35977)
Similar to GitHub, release notes can now be generated automatically. The generator is server-side and gathers the merged PRs and contributors and returns the corresponding Markdown text. --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -410,7 +410,7 @@ export class ComboMarkdownEditor {
|
||||
}
|
||||
}
|
||||
|
||||
export function getComboMarkdownEditor(el: any) {
|
||||
export function getComboMarkdownEditor(el: any): ComboMarkdownEditor | null {
|
||||
if (!el) return null;
|
||||
if (el.length) el = el[0];
|
||||
return el._giteaComboMarkdownEditor;
|
||||
|
||||
@@ -84,7 +84,7 @@ async function tryOnEditContent(e: Event) {
|
||||
showElem(editContentZone);
|
||||
hideElem(renderContent);
|
||||
|
||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'))!;
|
||||
if (!comboMarkdownEditor) {
|
||||
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template')!.innerHTML;
|
||||
const form = editContentZone.querySelector('form')!;
|
||||
@@ -139,7 +139,7 @@ async function tryOnQuoteReply(e: Event) {
|
||||
editor = await handleReply(replyBtn);
|
||||
} else {
|
||||
// for normal issue/comment page
|
||||
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'));
|
||||
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'))!;
|
||||
}
|
||||
|
||||
if (editor.value()) {
|
||||
|
||||
9
web_src/js/features/repo-release.test.ts
Normal file
9
web_src/js/features/repo-release.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {guessPreviousReleaseTag} from './repo-release.ts';
|
||||
|
||||
test('guessPreviousReleaseTag', async () => {
|
||||
expect(guessPreviousReleaseTag('v0.9', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('');
|
||||
expect(guessPreviousReleaseTag('1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2');
|
||||
expect(guessPreviousReleaseTag('rel/1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2');
|
||||
expect(guessPreviousReleaseTag('v1.3', ['rel/1.0', 'rel/1.2', 'rel/1.4', 'rel/1.6'])).toBe('rel/1.2');
|
||||
expect(guessPreviousReleaseTag('v2.0', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.6');
|
||||
});
|
||||
@@ -1,43 +1,47 @@
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {compareVersions} from 'compare-versions';
|
||||
|
||||
export function initRepoRelease() {
|
||||
document.addEventListener('click', (e: Event) => {
|
||||
if ((e.target as HTMLElement).matches('.remove-rel-attach')) {
|
||||
const uuid = (e.target as HTMLElement).getAttribute('data-uuid');
|
||||
const id = (e.target as HTMLElement).getAttribute('data-id');
|
||||
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
|
||||
hideElem(`#attachment-${id}`);
|
||||
}
|
||||
export function initRepoReleaseNew() {
|
||||
registerGlobalEventFunc('click', 'onReleaseEditAttachmentDelete', (el) => {
|
||||
const uuid = el.getAttribute('data-uuid')!;
|
||||
const id = el.getAttribute('data-id')!;
|
||||
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
|
||||
hideElem(`#attachment-${id}`);
|
||||
});
|
||||
registerGlobalInitFunc('initReleaseEditForm', (elForm: HTMLFormElement) => {
|
||||
initTagNameEditor(elForm);
|
||||
initGenerateReleaseNotes(elForm);
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoReleaseNew() {
|
||||
if (!document.querySelector('.repository.new.release')) return;
|
||||
|
||||
initTagNameEditor();
|
||||
function getReleaseFormExistingTags(elForm: HTMLFormElement): Array<string> {
|
||||
return JSON.parse(elForm.getAttribute('data-existing-tags')!);
|
||||
}
|
||||
|
||||
function initTagNameEditor() {
|
||||
const el = document.querySelector('#tag-name-editor');
|
||||
if (!el) return;
|
||||
function initTagNameEditor(elForm: HTMLFormElement) {
|
||||
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[type=text][name=tag_name]');
|
||||
if (!tagNameInput) return; // only init if tag name input exists (the tag name is editable)
|
||||
|
||||
const existingTags = JSON.parse(el.getAttribute('data-existing-tags')!);
|
||||
if (!Array.isArray(existingTags)) return;
|
||||
const existingTags = getReleaseFormExistingTags(elForm);
|
||||
const defaultTagHelperText = elForm.getAttribute('data-tag-helper');
|
||||
const newTagHelperText = elForm.getAttribute('data-tag-helper-new');
|
||||
const existingTagHelperText = elForm.getAttribute('data-tag-helper-existing');
|
||||
|
||||
const defaultTagHelperText = el.getAttribute('data-tag-helper');
|
||||
const newTagHelperText = el.getAttribute('data-tag-helper-new');
|
||||
const existingTagHelperText = el.getAttribute('data-tag-helper-existing');
|
||||
|
||||
const tagNameInput = document.querySelector<HTMLInputElement>('#tag-name')!;
|
||||
const hideTargetInput = function(tagNameInput: HTMLInputElement) {
|
||||
const value = tagNameInput.value;
|
||||
const tagHelper = document.querySelector('#tag-helper')!;
|
||||
const tagHelper = elForm.querySelector('.tag-name-helper')!;
|
||||
if (existingTags.includes(value)) {
|
||||
// If the tag already exists, hide the target branch selector.
|
||||
hideElem('#tag-target-selector');
|
||||
hideElem(elForm.querySelectorAll('.tag-target-selector'));
|
||||
tagHelper.textContent = existingTagHelperText;
|
||||
} else {
|
||||
showElem('#tag-target-selector');
|
||||
showElem(elForm.querySelectorAll('.tag-target-selector'));
|
||||
tagHelper.textContent = value ? newTagHelperText : defaultTagHelperText;
|
||||
}
|
||||
};
|
||||
@@ -46,3 +50,102 @@ function initTagNameEditor() {
|
||||
hideTargetInput(e.target as HTMLInputElement);
|
||||
});
|
||||
}
|
||||
|
||||
export function guessPreviousReleaseTag(tagName: string, existingTags: Array<string>): string {
|
||||
let guessedPreviousTag = '', guessedPreviousVer = '';
|
||||
|
||||
const cleanup = (s: string) => {
|
||||
const pos = s.lastIndexOf('/');
|
||||
if (pos >= 0) s = s.substring(pos + 1);
|
||||
if (s.substring(0, 1).toLowerCase() === 'v') s = s.substring(1);
|
||||
return s;
|
||||
};
|
||||
|
||||
const newVer = cleanup(tagName);
|
||||
for (const s of existingTags) {
|
||||
const existingVer = cleanup(s);
|
||||
try {
|
||||
if (compareVersions(existingVer, newVer) >= 0) continue;
|
||||
if (!guessedPreviousTag || compareVersions(existingVer, guessedPreviousVer) > 0) {
|
||||
guessedPreviousTag = s;
|
||||
guessedPreviousVer = existingVer;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return guessedPreviousTag;
|
||||
}
|
||||
|
||||
function initGenerateReleaseNotes(elForm: HTMLFormElement) {
|
||||
const buttonShowModal = elForm.querySelector<HTMLButtonElement>('.button.generate-release-notes')!;
|
||||
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[name=tag_name]')!;
|
||||
const targetInput = elForm.querySelector<HTMLInputElement>('input[name=tag_target]')!;
|
||||
|
||||
const textMissingTag = buttonShowModal.getAttribute('data-text-missing-tag')!;
|
||||
const generateUrl = buttonShowModal.getAttribute('data-generate-url')!;
|
||||
|
||||
const elModal = document.querySelector('#generate-release-notes-modal')!;
|
||||
|
||||
const doSubmit = async (tagName: string) => {
|
||||
const elPreviousTag = elModal.querySelector<HTMLSelectElement>('[name=previous_tag]')!;
|
||||
const comboEditor = getComboMarkdownEditor(elForm.querySelector<HTMLElement>('.combo-markdown-editor'))!;
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set('tag_name', tagName);
|
||||
form.set('tag_target', targetInput.value);
|
||||
form.set('previous_tag', elPreviousTag.value);
|
||||
|
||||
elModal.classList.add('loading', 'disabled');
|
||||
try {
|
||||
const resp = await POST(generateUrl, {data: form});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showErrorToast(data.errorMessage || resp.statusText);
|
||||
return;
|
||||
}
|
||||
const oldValue = comboEditor.value().trim();
|
||||
if (oldValue) {
|
||||
// Don't overwrite existing content. Maybe in the future we can let users decide: overwrite or append or copy-to-clipboard
|
||||
// GitHub just disables the button if the content is not empty
|
||||
comboEditor.value(`${oldValue}\n\n${data.content}`);
|
||||
} else {
|
||||
comboEditor.value(data.content);
|
||||
}
|
||||
} finally {
|
||||
elModal.classList.remove('loading', 'disabled');
|
||||
fomanticQuery(elModal).modal('hide');
|
||||
comboEditor.focus();
|
||||
}
|
||||
};
|
||||
|
||||
let inited = false;
|
||||
const doShowModal = () => {
|
||||
hideToastsAll();
|
||||
const tagName = tagNameInput.value.trim();
|
||||
if (!tagName) {
|
||||
showErrorToast(textMissingTag, {duration: 3000});
|
||||
tagNameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTags = getReleaseFormExistingTags(elForm);
|
||||
const $dropdown = fomanticQuery(elModal.querySelector('[name=previous_tag]')!);
|
||||
if (!inited) {
|
||||
inited = true;
|
||||
const values = [];
|
||||
for (const tagName of existingTags) {
|
||||
values.push({name: htmlEscape(tagName), value: tagName}); // ATTENTION: dropdown takes the "name" input as raw HTML
|
||||
}
|
||||
$dropdown.dropdown('change values', values);
|
||||
}
|
||||
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
|
||||
|
||||
fomanticQuery(elModal).modal({
|
||||
onApprove: () => {
|
||||
doSubmit(tagName); // don't await, need to return false to keep the modal
|
||||
return false;
|
||||
},
|
||||
}).modal('show');
|
||||
};
|
||||
|
||||
buttonShowModal.addEventListener('click', doShowModal);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
|
||||
import {initRepoDiffView} from './features/repo-diff.ts';
|
||||
import {initOrgTeam} from './features/org-team.ts';
|
||||
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
|
||||
import {initRepoRelease, initRepoReleaseNew} from './features/repo-release.ts';
|
||||
import {initRepoReleaseNew} from './features/repo-release.ts';
|
||||
import {initRepoEditor} from './features/repo-editor.ts';
|
||||
import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
|
||||
import {initInstall} from './features/install.ts';
|
||||
@@ -133,7 +133,6 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initRepoProject,
|
||||
initRepoPullRequestAllowMaintainerEdit,
|
||||
initRepoPullRequestReview,
|
||||
initRepoRelease,
|
||||
initRepoReleaseNew,
|
||||
initRepoTopicBar,
|
||||
initRepoViewFileTree,
|
||||
|
||||
@@ -5,7 +5,7 @@ export function initAriaFormFieldPatch() {
|
||||
for (const el of document.querySelectorAll('.ui.form .field')) {
|
||||
if (el.hasAttribute('data-field-patched')) continue;
|
||||
const label = el.querySelector(':scope > label');
|
||||
const input = el.querySelector(':scope > input');
|
||||
const input = el.querySelector(':scope > input, :scope > select');
|
||||
if (!label || !input) continue;
|
||||
linkLabelAndInput(label, input);
|
||||
el.setAttribute('data-field-patched', 'true');
|
||||
|
||||
Reference in New Issue
Block a user