diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 860199840847e..7c2a10698019a 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -32,7 +32,8 @@ This project incorporates components from the projects listed below. 27. react version 16.8.4 (https://github.com/facebook/react) 28. signal-utils version 0.20.0 (https://github.com/proposal-signals/signal-utils) 29. slug version 10.0.0 (https://github.com/Trott/slug) -30. sortablejs version 1.15.0 (https://github.com/SortableJS/Sortable) +30. slugify version 1.6.6 (https://github.com/simov/slugify) +31. sortablejs version 1.15.0 (https://github.com/SortableJS/Sortable) %% @gk-nzaytsev/fast-string-truncated-width NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -2219,6 +2220,33 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ========================================= END OF slug NOTICES AND INFORMATION +%% slugify NOTICES AND INFORMATION BEGIN HERE +========================================= +The MIT License (MIT) + +Copyright (c) Simeon Velichkov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +========================================= +END OF slugify NOTICES AND INFORMATION + %% sortablejs NOTICES AND INFORMATION BEGIN HERE ========================================= MIT License diff --git a/package.json b/package.json index 299cd48e1f275..a80d71a06b75b 100644 --- a/package.json +++ b/package.json @@ -20030,6 +20030,7 @@ "react-dom": "16.8.4", "signal-utils": "0.20.0", "slug": "10.0.0", + "slugify": "^1.6.6", "sortablejs": "1.15.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a605c267b14f6..b133cb85c89d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: slug: specifier: 10.0.0 version: 10.0.0 + slugify: + specifier: ^1.6.6 + version: 1.6.6 sortablejs: specifier: 1.15.0 version: 1.15.0 diff --git a/src/autolinks/__tests__/autolinks.test.ts b/src/autolinks/__tests__/autolinks.test.ts index f58f89b88d7c8..41af1e5d34a11 100644 --- a/src/autolinks/__tests__/autolinks.test.ts +++ b/src/autolinks/__tests__/autolinks.test.ts @@ -2,9 +2,9 @@ import * as assert from 'assert'; import { suite, test } from 'mocha'; import { map } from '../../system/iterable'; import type { Autolink, RefSet } from '../autolinks.utils'; -import { getAutolinks, getBranchAutolinks } from '../autolinks.utils'; +import { calculatePriority, getAutolinks, getBranchAutolinks } from '../autolinks.utils'; -const mockRefSets = (prefixes: string[] = ['']): RefSet[] => +const mockRefSets = (prefixes: string[] = [''], title = 'test'): RefSet[] => prefixes.map(prefix => [ { domain: 'test', icon: '1', id: '1', name: 'test' }, [ @@ -12,7 +12,7 @@ const mockRefSets = (prefixes: string[] = ['']): RefSet[] => alphanumeric: false, ignoreCase: false, prefix: prefix, - title: 'test', + title: title, url: '', description: 'test', }, @@ -48,6 +48,24 @@ suite('Autolinks Test Suite', () => { assertAutolinks(getAutolinks('test message 123 sd', mockRefSets()), ['123']); }); + test('Test autolink priority comparation', () => { + assert.equal( + calculatePriority('1', 0, '1', 1) > calculatePriority('1', 0, '1', 0) || 'bigger chunk fall', + true, + ); + assert.equal( + calculatePriority('1', 0, '1', 2) > calculatePriority('1', 1, '1', 0) || 'less edge distance fall', + true, + ); + assert.equal( + calculatePriority('1', 0, '1', 2) > calculatePriority('1', 0, '1.1', 2) || 'single number fall', + true, + ); + assert.equal( + calculatePriority('2', 0, '2', 2) > calculatePriority('1', 0, '1', 2) || 'bigger number fall', + true, + ); + }); /** * 16.1.1^ - improved branch name autolinks matching */ @@ -57,7 +75,14 @@ suite('Autolinks Test Suite', () => { assertAutolinks(getBranchAutolinks('folder/release/16/issue-1', mockRefSets([''])), ['1']); assertAutolinks(getBranchAutolinks('folder/release/16.1/issue-1', mockRefSets([''])), ['1']); assertAutolinks(getBranchAutolinks('folder/release/16.1.1/1', mockRefSets([''])), ['1']); - // skip one in case of single chunk + assertAutolinks(getBranchAutolinks('release-2024', mockRefSets([''])), []); + assertAutolinks(getBranchAutolinks('v-2024', mockRefSets([''])), []); + assertAutolinks(getBranchAutolinks('v2024', mockRefSets([''])), []); + // cannot be definitely handled + assertAutolinks(getBranchAutolinks('some-issue-in-release-2024', mockRefSets([''])), ['2024']); + assertAutolinks(getBranchAutolinks('folder/release-notes-16-1', mockRefSets([''])), ['16']); + assertAutolinks(getBranchAutolinks('folder/16-1-release-notes', mockRefSets([''])), ['16']); + // skip next in case of single chunk assertAutolinks(getBranchAutolinks('folder/release-16/1', mockRefSets([''])), ['1']); assertAutolinks(getBranchAutolinks('folder/release-16.1/1', mockRefSets([''])), ['1']); assertAutolinks(getBranchAutolinks('folder/release-16.1.2/1', mockRefSets([''])), ['1']); @@ -66,18 +91,11 @@ suite('Autolinks Test Suite', () => { * Added chunk matching logic for non-prefixed numbers: * - XX - is more likely issue number * - XX.XX - is less likely issue number, but still possible - * - XX.XX.XX - is more likely not issue number + * - XX.XX.XX - is more likely not issue number: seems like a date or version number */ - assertAutolinks(getBranchAutolinks('some-issue-in-release-2024', mockRefSets([''])), ['2024']); - assertAutolinks(getBranchAutolinks('some-issue-in-release-2024.1', mockRefSets([''])), ['2024']); - assertAutolinks(getBranchAutolinks('some-issue-in-release-2024.1.1', mockRefSets([''])), []); - - assertAutolinks(getBranchAutolinks('folder/release-notes-16-1', mockRefSets([''])), ['16']); - assertAutolinks(getBranchAutolinks('folder/16-1-release-notes', mockRefSets([''])), ['16']); - - // considered the distance from the edges of the chunk as a priority sign - assertAutolinks(getBranchAutolinks('folder/16-content-1-content', mockRefSets([''])), ['16', '1']); - assertAutolinks(getBranchAutolinks('folder/content-1-content-16', mockRefSets([''])), ['16', '1']); + assertAutolinks(getBranchAutolinks('issue-2024', mockRefSets([''])), ['2024']); + assertAutolinks(getBranchAutolinks('issue-2024.1', mockRefSets([''])), ['2024']); + assertAutolinks(getBranchAutolinks('issue-2024.1.1', mockRefSets([''])), []); // the chunk that is more close to the end is more likely actual issue number assertAutolinks(getBranchAutolinks('1-epic-folder/10-issue/100-subissue', mockRefSets([''])), [ @@ -85,5 +103,21 @@ suite('Autolinks Test Suite', () => { '10', '1', ]); + + // ignore numbers from title + assertAutolinks(getBranchAutolinks('folder/100-content-content-16', mockRefSets([''], '100-content-content')), [ + '16', + ]); + assertAutolinks(getBranchAutolinks('folder/100-content-content-16', mockRefSets([''], 'content-content-16')), [ + '100', + ]); + + // consider edge distance and issue key length to sort + assertAutolinks(getBranchAutolinks('2-some-issue-in-release-2024', mockRefSets([''])), ['2024', '2']); + assertAutolinks(getBranchAutolinks('2024-some-issue-in-release-2', mockRefSets([''])), ['2024', '2']); + assertAutolinks(getBranchAutolinks('some-2-issue-in-release-2024', mockRefSets([''])), ['2024', '2']); + assertAutolinks(getBranchAutolinks('4048-issue-in-release-2024.1', mockRefSets([''])), ['4048', '2024']); + // less numbers - more likely issue key + assertAutolinks(getBranchAutolinks('1-issue-in-release-2024.1', mockRefSets([''])), ['1', '2024']); }); }); diff --git a/src/autolinks/autolinks.utils.ts b/src/autolinks/autolinks.utils.ts index f75941e2228b5..6edc3d736e1ab 100644 --- a/src/autolinks/autolinks.utils.ts +++ b/src/autolinks/autolinks.utils.ts @@ -1,8 +1,9 @@ +import slugify from 'slugify'; import { IssueIntegrationId } from '../constants.integrations'; import type { IssueOrPullRequest } from '../git/models/issue'; import type { ProviderReference } from '../git/models/remoteProvider'; import type { ResourceDescriptor } from '../plus/integrations/integration'; -import { flatMap } from '../system/iterable'; +import { flatMap, forEach } from '../system/iterable'; import { escapeMarkdown } from '../system/markdown'; import type { MaybePausedResult } from '../system/promise'; import { encodeHtmlWeak, escapeRegex } from '../system/string'; @@ -117,7 +118,7 @@ export function isDynamic(ref: AutolinkReference | DynamicAutolinkReference): re return !('prefix' in ref) && !('url' in ref); } -function isCacheable(ref: AutolinkReference | DynamicAutolinkReference): ref is CacheableAutolinkReference { +export function isCacheable(ref: AutolinkReference | DynamicAutolinkReference): ref is CacheableAutolinkReference { return 'prefix' in ref && ref.prefix != null && 'url' in ref && ref.url != null; } @@ -131,9 +132,11 @@ export type RefSet = [ * @returns non-0 result that means a probability of the autolink `b` is more relevant of the autolink `a` */ function compareAutolinks(a: Autolink, b: Autolink): number { + // believe that link with prefix is definitely more relevant that just a number if (b.prefix.length - a.prefix.length) { return b.prefix.length - a.prefix.length; } + // if custom priority provided, let's consider it first if (a.priority || b.priority) { if ((b.priority ?? '') > (a.priority ?? '')) { return 1; @@ -141,7 +144,6 @@ function compareAutolinks(a: Autolink, b: Autolink): number { if ((b.priority ?? '') < (a.priority ?? '')) { return -1; } - return 0; } // consider that if the number is in the start, it's the most relevant link if (b.index === 0) return 1; @@ -184,6 +186,7 @@ function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html' ref.ignoreCase ? 'gi' : 'g', ); if (!ref.prefix && !ref.alphanumeric) { + // use a different regex for non-prefixed refs ref.branchNameRegex = /(?^|\/|-|_)(?(?\d+)(((-|\.|_)\d+){0,1}))(?$|\/|-|_)/gi; } else { @@ -245,19 +248,19 @@ export function getAutolinks(message: string, refsets: Readonly) { return autolinks; } -function calculatePriority( - input: string, +/** returns lexicographic priority value ready to sort */ +export function calculatePriority( issueKey: string, + edgeDistance: number, numberGroup: string, - index: number, chunkIndex: number = 0, ): string { - const edgeDistance = Math.min(index, input.length - index + numberGroup.length - 1); const isSingleNumber = issueKey === numberGroup; return ` - ${String.fromCharCode('a'.charCodeAt(0) + chunkIndex)}: - ${String.fromCharCode('a'.charCodeAt(0) - edgeDistance)}: - ${String.fromCharCode('a'.charCodeAt(0) + Number(isSingleNumber))} + ${String.fromCharCode('a'.charCodeAt(0) + chunkIndex)}:\ + ${String.fromCharCode('a'.charCodeAt(0) - edgeDistance)}:\ + ${String.fromCharCode('a'.charCodeAt(0) + Number(isSingleNumber))}:\ + ${String.fromCharCode('a'.charCodeAt(0) + Number(issueKey))} `; } @@ -265,7 +268,6 @@ export function getBranchAutolinks(branchName: string, refsets: Readonly(); let match; - let num; for (const [provider, refs] of refsets) { for (const ref of refs) { if ( @@ -278,16 +280,19 @@ export function getBranchAutolinks(branchName: string, refsets: Readonly(); + const chunkIndexMap = new Map(); let skip = false; - const matches = flatMap(chunks, chunk => { - const releaseMatch = /^release(s?)((?-[\d.-]+)?)$/gm.exec(chunk); + // know chunk indexes, skip release-like chunks or chunk pairs like release-1 or release/1 + let matches: IterableIterator | undefined = flatMap(chunks, chunk => { + const releaseMatch = /^(v|ver?|versions?|releases?)((?[\d.-]+)?)$/gm.exec(chunk); if (releaseMatch) { + // number in the next chunk should be ignored if (!releaseMatch.groups?.releaseNum) { skip = true; } @@ -298,48 +303,63 @@ export function getBranchAutolinks(branchName: string, refsets: Readonly[] = []; + /** indicates that we should remove any matched link from the map */ + let unwanted = false; do { - match = matches.next(); - if (!match.value?.groups) break; + match = matches?.next(); + if (match?.done && refTitlesMatches.length) { + // check ref titles on unwanted matches + matches = refTitlesMatches.shift(); + unwanted = true; + continue; + } + if (!match?.value?.groups) break; - num = match?.value?.groups.issueKeyNumber; + const { issueKeyNumber: issueKey, numberChunk = issueKey } = match.value.groups; + const input = match.value.input; let index = match.value.index; - const linkUrl = ref.url?.replace(numRegex, num); + const entryEdgeDistance = Math.min(index, input.length - index - numberChunk.length - 1); + + const linkUrl = ref.url?.replace(numRegex, issueKey); // strange case (I would say synthetic), but if we parse the link twice, use the most relevant of them const existingIndex = autolinks.get(linkUrl)?.index; if (existingIndex != null) { index = Math.min(index, existingIndex); } - console.log( - JSON.stringify(match.value), - match.value.groups.numberChunk, - match.value.groups.numberChunkBeginning, - match.value.input, - match.value.groups.issueKeyNumber, - ); - autolinks.set(linkUrl, { - ...ref, - provider: provider, - id: num, - index: index, - url: linkUrl, - priority: nonPrefixedRef - ? calculatePriority( - match.value.input, - num, - match.value.groups.numberChunk, - index, - chunkMap.get(match.value.input), - ) - : undefined, - title: ref.title?.replace(numRegex, num), - description: ref.description?.replace(numRegex, num), - descriptor: ref.descriptor, - }); - } while (!match.done); + + // fill refTitlesMatches for non-prefixed refs + if (!unwanted && nonPrefixedRef && ref.title) { + refTitlesMatches.push(slugify(ref.title).matchAll(ref.branchNameRegex)); + } + + if (!unwanted) { + autolinks.set(linkUrl, { + ...ref, + provider: provider, + id: issueKey, + index: index, + url: linkUrl, + priority: nonPrefixedRef + ? calculatePriority( + issueKey, + entryEdgeDistance, + match.value.groups.numberChunk, + chunkIndexMap.get(match.value.input), + ) + : undefined, + title: ref.title?.replace(numRegex, issueKey), + description: ref.description?.replace(numRegex, issueKey), + descriptor: ref.descriptor, + }); + } else { + autolinks.delete(linkUrl); + } + } while (true); } }