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:
Dawid Góra
2025-12-17 03:01:19 +01:00
committed by GitHub
parent 14911d4293
commit 0e916c67cc
17 changed files with 629 additions and 173 deletions

View File

@@ -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;

View File

@@ -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()) {

View 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');
});

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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');