diff --git a/.eslintrc.js b/.eslintrc.js index 78f298a23e44d1..7b963dad11b1c4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -51,6 +51,9 @@ module.exports = { memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], }, ], + + // mdast is a types only package `@types/mdast` + 'import/no-unresolved': ['error', { ignore: ['^mdast$'] }], 'import/order': [ 'error', { diff --git a/lib/platform/github/__snapshots__/index.spec.ts.snap b/lib/platform/github/__snapshots__/index.spec.ts.snap index bf111b17ecb9af..37ff4dedb235e2 100644 --- a/lib/platform/github/__snapshots__/index.spec.ts.snap +++ b/lib/platform/github/__snapshots__/index.spec.ts.snap @@ -7772,7 +7772,7 @@ Array [ ] `; -exports[`platform/github/index massageMarkdown(input) returns updated pr body 1`] = `"https://github.com/foo/bar/issues/5 plus also [a link](https://togithub.com/foo/bar/issues/5)"`; +exports[`platform/github/index massageMarkdown(input) returns updated pr body 1`] = `"[https://github.com/foo/bar/issues/5](https://togithub.com/foo/bar/issues/5) plus also [a link](https://togithub.com/foo/bar/issues/5)"`; exports[`platform/github/index mergePr(prNo) - autodetection should give up 1`] = ` Array [ diff --git a/lib/platform/github/index.ts b/lib/platform/github/index.ts index 0b7b05f7419141..a4c7070edbd720 100644 --- a/lib/platform/github/index.ts +++ b/lib/platform/github/index.ts @@ -49,6 +49,7 @@ import { repoInfoQuery, vulnerabilityAlertsQuery, } from './graphql'; +import { massageMarkdownLinks } from './massage-markdown-links'; import { BranchProtection, CombinedBranchStatus, @@ -1569,7 +1570,7 @@ export function massageMarkdown(input: string): string { if (config.isGhe) { return smartTruncate(input, 60000); } - const massagedInput = input + const massagedInput = massageMarkdownLinks(input) // to be safe, replace all github.com links with renovatebot redirector .replace(/href="https?:\/\/github.com\//g, 'href="https://togithub.com/') .replace(/]\(https:\/\/github\.com\//g, '](https://togithub.com/') diff --git a/lib/platform/github/massage-markdown-links.spec.ts b/lib/platform/github/massage-markdown-links.spec.ts new file mode 100644 index 00000000000000..536ea879d0300d --- /dev/null +++ b/lib/platform/github/massage-markdown-links.spec.ts @@ -0,0 +1,90 @@ +import { getName } from '../../../test/util'; +import { massageMarkdownLinks } from './massage-markdown-links'; + +describe(getName(), () => { + it('performs multiple replacements', () => { + const input = [ + 'Link [foo/bar#1](https://github.com/foo/bar/pull/1) points to https://github.com/foo/bar/pull/1.', + 'URL https://github.com/foo/bar/pull/1 becomes [foo/bar#1](https://github.com/foo/bar/pull/1).', + ].join('\n'); + const res = massageMarkdownLinks(input); + expect(res).toEqual( + [ + 'Link [foo/bar#1](https://togithub.com/foo/bar/pull/1) points to [https://github.com/foo/bar/pull/1](https://togithub.com/foo/bar/pull/1).', + 'URL [https://github.com/foo/bar/pull/1](https://togithub.com/foo/bar/pull/1) becomes [foo/bar#1](https://togithub.com/foo/bar/pull/1).', + ].join('\n') + ); + }); + + test.each` + input + ${'github.com'} + ${'github.com/foo/bar'} + ${'github.com/foo/bar/'} + ${'github.com/foo/bar/discussions'} + ${'github.com/foo/bar/issues'} + ${'github.com/foo/bar/pull'} + ${'github.com/foo/bar/discussions/'} + ${'github.com/foo/bar/issues/'} + ${'github.com/foo/bar/pull/'} + ${'www.github.com'} + ${'www.github.com/foo/bar'} + ${'www.github.com/foo/bar/'} + ${'www.github.com/foo/bar/discussions'} + ${'www.github.com/foo/bar/issues'} + ${'www.github.com/foo/bar/pull'} + ${'www.github.com/foo/bar/discussions/'} + ${'www.github.com/foo/bar/issues/'} + ${'www.github.com/foo/bar/pull/'} + ${'https://github.com'} + ${'https://github.com/foo/bar'} + ${'https://github.com/foo/bar/'} + ${'https://github.com/foo/bar/discussions'} + ${'https://github.com/foo/bar/issues'} + ${'https://github.com/foo/bar/pull'} + ${'https://github.com/foo/bar/discussions/'} + ${'https://github.com/foo/bar/issues/'} + ${'https://github.com/foo/bar/pull/'} + ${'api.github.com'} + ${'togithub.com'} + ${'www.togithub.com'} + ${'https://togithub.com/foo/bar/releases/tag/v0.20.3'} + ${'https://togithub.com/foo/bar/compare/v0.20.2...v0.20.3'} + `('Unchanged: $input', ({ input }: { input: string }) => { + const inputText = `Foo ${input}, bar.`; + expect(massageMarkdownLinks(inputText)).toEqual(inputText); + + const inputLink = `[foobar](${input})`; + expect(massageMarkdownLinks(inputLink)).toEqual(inputLink); + }); + + test.each` + input | output + ${'github.com/foo/bar/discussions/1'} | ${'[github.com/foo/bar/discussions/1](togithub.com/foo/bar/discussions/1)'} + ${'github.com/foo/bar/issues/1'} | ${'[github.com/foo/bar/issues/1](togithub.com/foo/bar/issues/1)'} + ${'github.com/foo/bar/pull/1'} | ${'[github.com/foo/bar/pull/1](togithub.com/foo/bar/pull/1)'} + ${'www.github.com/foo/bar/discussions/1'} | ${'[www.github.com/foo/bar/discussions/1](www.togithub.com/foo/bar/discussions/1)'} + ${'www.github.com/foo/bar/issues/1'} | ${'[www.github.com/foo/bar/issues/1](www.togithub.com/foo/bar/issues/1)'} + ${'www.github.com/foo/bar/pull/1'} | ${'[www.github.com/foo/bar/pull/1](www.togithub.com/foo/bar/pull/1)'} + ${'https://github.com/foo/bar/discussions/1'} | ${'[https://github.com/foo/bar/discussions/1](https://togithub.com/foo/bar/discussions/1)'} + ${'https://github.com/foo/bar/issues/1'} | ${'[https://github.com/foo/bar/issues/1](https://togithub.com/foo/bar/issues/1)'} + ${'https://github.com/foo/bar/pull/1'} | ${'[https://github.com/foo/bar/pull/1](https://togithub.com/foo/bar/pull/1)'} + ${'https://github.com/foo/bar/discussions/1#comment-123'} | ${'[https://github.com/foo/bar/discussions/1#comment-123](https://togithub.com/foo/bar/discussions/1#comment-123)'} + ${'https://github.com/foo/bar/issues/1#comment-123'} | ${'[https://github.com/foo/bar/issues/1#comment-123](https://togithub.com/foo/bar/issues/1#comment-123)'} + ${'https://github.com/foo/bar/pull/1#comment-123'} | ${'[https://github.com/foo/bar/pull/1#comment-123](https://togithub.com/foo/bar/pull/1#comment-123)'} + ${'[github.com/foo/bar/discussions/1](github.com/foo/bar/discussions/1)'} | ${'[github.com/foo/bar/discussions/1](togithub.com/foo/bar/discussions/1)'} + ${'[github.com/foo/bar/issues/1](github.com/foo/bar/issues/1)'} | ${'[github.com/foo/bar/issues/1](togithub.com/foo/bar/issues/1)'} + ${'[github.com/foo/bar/pull/1](github.com/foo/bar/pull/1)'} | ${'[github.com/foo/bar/pull/1](togithub.com/foo/bar/pull/1)'} + ${'[www.github.com/foo/bar/discussions/1](www.github.com/foo/bar/discussions/1)'} | ${'[www.github.com/foo/bar/discussions/1](www.togithub.com/foo/bar/discussions/1)'} + ${'[www.github.com/foo/bar/issues/1](www.github.com/foo/bar/issues/1)'} | ${'[www.github.com/foo/bar/issues/1](www.togithub.com/foo/bar/issues/1)'} + ${'[www.github.com/foo/bar/pull/1](www.github.com/foo/bar/pull/1)'} | ${'[www.github.com/foo/bar/pull/1](www.togithub.com/foo/bar/pull/1)'} + ${'[https://github.com/foo/bar/discussions/1](https://github.com/foo/bar/discussions/1)'} | ${'[https://github.com/foo/bar/discussions/1](https://togithub.com/foo/bar/discussions/1)'} + ${'[https://github.com/foo/bar/issues/1](https://github.com/foo/bar/issues/1)'} | ${'[https://github.com/foo/bar/issues/1](https://togithub.com/foo/bar/issues/1)'} + ${'[https://github.com/foo/bar/pull/1](https://github.com/foo/bar/pull/1)'} | ${'[https://github.com/foo/bar/pull/1](https://togithub.com/foo/bar/pull/1)'} + `( + '$input -> $output', + ({ input, output }: { input: string; output: string }) => { + expect(massageMarkdownLinks(input)).toEqual(output); + } + ); +}); diff --git a/lib/platform/github/massage-markdown-links.ts b/lib/platform/github/massage-markdown-links.ts new file mode 100644 index 00000000000000..7c788b3f67f020 --- /dev/null +++ b/lib/platform/github/massage-markdown-links.ts @@ -0,0 +1,71 @@ +import type { Content } from 'mdast'; +import remark from 'remark'; +import type { Plugin, Transformer } from 'unified'; +import { logger } from '../../logger'; +import { hasKey } from '../../util/object'; + +interface UrlMatch { + start: number; + end: number; + replaceTo: string; +} + +const urlRegex = + /(?:https?:)?(?:\/\/)?(?:www\.)?(? { + const startOffset: number = tree.position.start.offset; + const endOffset: number = tree.position.end.offset; + + if (tree.type === 'link') { + const substr = input.slice(startOffset, endOffset); + const url: string = tree.url; + const offset: number = startOffset + substr.lastIndexOf(url); + if (urlRegex.test(url)) { + matches.push({ + start: offset, + end: offset + url.length, + replaceTo: massageLink(url), + }); + } + } else if (tree.type === 'text') { + const globalUrlReg = new RegExp(urlRegex, 'g'); + const urlMatches = [...tree.value.matchAll(globalUrlReg)]; + for (const match of urlMatches) { + const [url] = match; + const start = startOffset + match.index; + const end = start + url.length; + const newUrl = massageLink(url); + matches.push({ start, end, replaceTo: `[${url}](${newUrl})` }); + } + } else if (hasKey('children', tree)) { + tree.children.forEach((child: Content) => { + transformer(child); + }); + } + }; + + return () => transformer as Transformer; +} + +export function massageMarkdownLinks(content: string): string { + try { + const rightSpaces = content.replace(content.trimRight(), ''); + const matches: UrlMatch[] = []; + remark().use(collectLinkPosition(content, matches)).processSync(content); + const result = matches.reduceRight((acc, { start, end, replaceTo }) => { + const leftPart = acc.slice(0, start); + const rightPart = acc.slice(end); + return leftPart + replaceTo + rightPart; + }, content); + return result.trimRight() + rightSpaces; + } catch (err) /* istanbul ignore next */ { + logger.warn({ err }, `Unable to massage markdown text`); + return content; + } +}