diff --git a/lib/modules/manager/gomod/artifacts-extra.spec.ts b/lib/modules/manager/gomod/artifacts-extra.spec.ts new file mode 100644 index 00000000000000..ad6a9e1917d3c3 --- /dev/null +++ b/lib/modules/manager/gomod/artifacts-extra.spec.ts @@ -0,0 +1,138 @@ +import { codeBlock } from 'common-tags'; +import { + extraDepsTable, + getExtraDeps, + getExtraDepsNotice, +} from './artifacts-extra'; +import type { ExtraDep } from './types'; + +describe('modules/manager/gomod/artifacts-extra', () => { + const goModBefore = codeBlock` + go 1.22.0 + + require ( + github.com/foo/foo v1.0.0 + github.com/bar/bar v2.0.0 + ) + + replace baz/baz => qux/qux + `; + + const goModAfter = codeBlock` + go 1.22.2 + + // Note the order change + require ( + github.com/bar/bar v2.2.2 + github.com/foo/foo v1.1.1 + ) + + replace baz/baz => quux/quux + `; + + describe('getExtraDeps', () => { + it('detects extra dependencies', () => { + const excludeDeps = ['github.com/foo/foo']; + + const res = getExtraDeps(goModBefore, goModAfter, excludeDeps); + + expect(res).toEqual([ + { + depName: 'go', + currentValue: '1.22.0', + newValue: '1.22.2', + }, + { + depName: 'github.com/bar/bar', + currentValue: 'v2.0.0', + newValue: 'v2.2.2', + }, + ] satisfies ExtraDep[]); + }); + }); + + describe('extraDepsTable', () => { + it('generates a table', () => { + const extraDeps: ExtraDep[] = [ + { + depName: 'github.com/foo/foo', + currentValue: 'v1.0.0', + newValue: 'v1.1.1', + }, + { + depName: 'github.com/bar/bar', + currentValue: 'v2.0.0', + newValue: 'v2.2.2', + }, + ]; + + const res = extraDepsTable(extraDeps); + + expect(res).toEqual( + [ + '| **Package** | **Change** |', + '| :------------------- | :------------------- |', + '| `github.com/foo/foo` | `v1.0.0` -> `v1.1.1` |', + '| `github.com/bar/bar` | `v2.0.0` -> `v2.2.2` |', + ].join('\n'), + ); + }); + }); + + describe('getExtraDepsNotice', () => { + it('returns null when one of files is missing', () => { + expect(getExtraDepsNotice(null, goModAfter, [])).toBeNull(); + expect(getExtraDepsNotice(goModBefore, null, [])).toBeNull(); + }); + + it('returns null when all dependencies are excluded', () => { + const excludeDeps = ['go', 'github.com/foo/foo', 'github.com/bar/bar']; + const res = getExtraDepsNotice(goModBefore, goModAfter, excludeDeps); + expect(res).toBeNull(); + }); + + it('returns a notice when there are extra dependencies', () => { + const excludeDeps = ['go', 'github.com/foo/foo']; + + const res = getExtraDepsNotice(goModBefore, goModAfter, excludeDeps); + + expect(res).toEqual( + [ + 'In order to perform the update(s) described in the table above, Renovate ran the `go get` command, which resulted in the following additional change(s):', + '', + '', + '- 1 additional dependency was updated', + '', + '', + 'Details:', + '| **Package** | **Change** |', + '| :------------------- | :------------------- |', + '| `github.com/bar/bar` | `v2.0.0` -> `v2.2.2` |', + ].join('\n'), + ); + }); + + it('adds special notice for updated `go` version', () => { + const excludeDeps = ['github.com/foo/foo']; + + const res = getExtraDepsNotice(goModBefore, goModAfter, excludeDeps); + + expect(res).toEqual( + [ + 'In order to perform the update(s) described in the table above, Renovate ran the `go get` command, which resulted in the following additional change(s):', + '', + '', + '- 1 additional dependency was updated', + '- The `go` directive was updated for compatibility reasons', + '', + '', + 'Details:', + '| **Package** | **Change** |', + '| :------------------- | :------------------- |', + '| `go` | `1.22.0` -> `1.22.2` |', + '| `github.com/bar/bar` | `v2.0.0` -> `v2.2.2` |', + ].join('\n'), + ); + }); + }); +}); diff --git a/lib/modules/manager/gomod/artifacts-extra.ts b/lib/modules/manager/gomod/artifacts-extra.ts new file mode 100644 index 00000000000000..fbebc9efc20572 --- /dev/null +++ b/lib/modules/manager/gomod/artifacts-extra.ts @@ -0,0 +1,112 @@ +import { diffLines } from 'diff'; +import markdownTable from 'markdown-table'; +import { parseLine } from './line-parser'; +import type { ExtraDep } from './types'; + +export function getExtraDeps( + goModBefore: string, + goModAfter: string, + excludeDeps: string[], +): ExtraDep[] { + const result: ExtraDep[] = []; + + const diff = diffLines(goModBefore, goModAfter, { + newlineIsToken: true, + }); + + const addDeps: Record = {}; + const rmDeps: Record = {}; + for (const { added, removed, value } of diff) { + if (!added && !removed) { + continue; + } + + const res = parseLine(value); + if (!res) { + continue; + } + + const { depName, currentValue } = res; + if (!depName || !currentValue) { + continue; + } + + if (added) { + addDeps[depName] = currentValue; + } else { + rmDeps[depName] = currentValue; + } + } + + for (const [depName, currentValue] of Object.entries(rmDeps)) { + if (excludeDeps.includes(depName)) { + continue; + } + + const newValue = addDeps[depName]; + if (newValue) { + result.push({ + depName, + currentValue, + newValue, + }); + } + } + + return result; +} + +export function extraDepsTable(extraDeps: ExtraDep[]): string { + const tableLines: string[][] = []; + + tableLines.push(['**Package**', '**Change**']); + + for (const { depName, currentValue, newValue } of extraDeps) { + const depNameQuoted = `\`${depName}\``; + const versionChangeQuoted = `\`${currentValue}\` -> \`${newValue}\``; + tableLines.push([depNameQuoted, versionChangeQuoted]); + } + + return markdownTable(tableLines, { + align: ['l', 'l'], + }); +} + +export function getExtraDepsNotice( + goModBefore: string | null, + goModAfter: string | null, + excludeDeps: string[], +): string | null { + if (!goModBefore || !goModAfter) { + return null; + } + + const extraDeps = getExtraDeps(goModBefore, goModAfter, excludeDeps); + if (extraDeps.length === 0) { + return null; + } + + const noticeLines: string[] = [ + 'In order to perform the update(s) described in the table above, Renovate ran the `go get` command, which resulted in the following additional change(s):', + '\n', + ]; + + const goUpdated = extraDeps.some(({ depName }) => depName === 'go'); + const otherDepsCount = extraDeps.length - (goUpdated ? 1 : 0); + + if (otherDepsCount > 0) { + noticeLines.push(`- ${otherDepsCount} additional dependency was updated`); + } + + if (goUpdated) { + noticeLines.push( + '- The `go` directive was updated for compatibility reasons', + ); + } + + noticeLines.push('\n'); + noticeLines.push('Details:'); + noticeLines.push(extraDepsTable(extraDeps)); + + return noticeLines.join('\n'); +} diff --git a/lib/modules/manager/gomod/artifacts.spec.ts b/lib/modules/manager/gomod/artifacts.spec.ts index b4bdd0e21b7efd..42d09c0a0602e8 100644 --- a/lib/modules/manager/gomod/artifacts.spec.ts +++ b/lib/modules/manager/gomod/artifacts.spec.ts @@ -10,6 +10,7 @@ import type { StatusResult } from '../../../util/git/types'; import * as _hostRules from '../../../util/host-rules'; import * as _datasource from '../../datasource'; import type { UpdateArtifactsConfig } from '../types'; +import * as _artifactsExtra from './artifacts-extra'; import * as gomod from '.'; type FS = typeof import('../../../util/fs'); @@ -28,11 +29,13 @@ jest.mock('../../../util/fs', () => { }; }); jest.mock('../../datasource', () => mockDeep()); +jest.mock('./artifacts-extra', () => mockDeep()); process.env.CONTAINERBASE = 'true'; const datasource = mocked(_datasource); const hostRules = mocked(_hostRules); +const artifactsExtra = mocked(_artifactsExtra); const gomod1 = codeBlock` module github.com/renovate-tests/gomod1 @@ -1866,6 +1869,47 @@ describe('modules/manager/gomod/artifacts', () => { expect(execSnapshots).toMatchObject(expectedResult); }); + it('returns artifact notices', async () => { + artifactsExtra.getExtraDepsNotice.mockReturnValue('some extra notice'); + GlobalConfig.set({ ...adminConfig, binarySource: 'docker' }); + fs.readLocalFile.mockResolvedValueOnce('Current go.sum'); + fs.readLocalFile.mockResolvedValueOnce(null); // vendor modules filename + fs.readLocalFile.mockResolvedValueOnce('someText\n\ngo 1.17\n\n'); + mockExecAll(); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: ['go.sum', 'main.go'], + }), + ); + fs.readLocalFile + .mockResolvedValueOnce('New go.sum') + .mockResolvedValueOnce('New main.go') + .mockResolvedValueOnce('New go.mod'); + datasource.getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '1.17.0' }, { version: '1.14.0' }], + }); + const res = await gomod.updateArtifacts({ + packageFileName: 'go.mod', + updatedDeps: [ + { depName: 'github.com/google/go-github/v24', newVersion: 'v28.0.0' }, + ], + newPackageFileContent: gomod1, + config: { + updateType: 'major', + postUpdateOptions: ['gomodUpdateImportPaths'], + }, + }); + + expect(res).toEqual([ + { file: { type: 'addition', path: 'go.sum', contents: 'New go.sum' } }, + { file: { type: 'addition', path: 'main.go', contents: 'New main.go' } }, + { + file: { type: 'addition', path: 'go.mod', contents: 'New go.mod' }, + notice: { file: 'go.mod', message: 'some extra notice' }, + }, + ]); + }); + it('config contains go version', async () => { GlobalConfig.set({ ...adminConfig, binarySource: 'docker' }); fs.readLocalFile.mockResolvedValueOnce('Current go.sum'); diff --git a/lib/modules/manager/gomod/artifacts.ts b/lib/modules/manager/gomod/artifacts.ts index 0795019a3dceff..fa1c13a573e0b4 100644 --- a/lib/modules/manager/gomod/artifacts.ts +++ b/lib/modules/manager/gomod/artifacts.ts @@ -8,6 +8,7 @@ import { logger } from '../../../logger'; import { coerceArray } from '../../../util/array'; import { exec } from '../../../util/exec'; import type { ExecOptions } from '../../../util/exec/types'; +import { filterMap } from '../../../util/filter-map'; import { ensureCacheDir, isValidLocalPath, @@ -24,6 +25,7 @@ import type { UpdateArtifactsConfig, UpdateArtifactsResult, } from '../types'; +import { getExtraDepsNotice } from './artifacts-extra'; const { major, valid } = semver; @@ -364,14 +366,30 @@ export async function updateArtifacts({ .replace(regEx(/\/\/ renovate-replace /g), '') .replace(regEx(/renovate-replace-bracket/g), ')'); if (finalGoModContent !== newGoModContent) { - logger.debug('Found updated go.mod after go.sum update'); - res.push({ + const artifactResult: UpdateArtifactsResult = { file: { type: 'addition', path: goModFileName, contents: finalGoModContent, }, - }); + }; + + const updatedDepNames = filterMap(updatedDeps, (dep) => dep?.depName); + const extraDepsNotice = getExtraDepsNotice( + newGoModContent, + finalGoModContent, + updatedDepNames, + ); + + if (extraDepsNotice) { + artifactResult.notice = { + file: goModFileName, + message: extraDepsNotice, + }; + } + + logger.debug('Found updated go.mod after go.sum update'); + res.push(artifactResult); } return res; } catch (err) { diff --git a/lib/modules/manager/gomod/types.ts b/lib/modules/manager/gomod/types.ts index 70c3dace8b1b2b..85e505f1275f80 100644 --- a/lib/modules/manager/gomod/types.ts +++ b/lib/modules/manager/gomod/types.ts @@ -4,3 +4,9 @@ export interface MultiLineParseResult { reachedLine: number; detectedDeps: PackageDependency[]; } + +export interface ExtraDep { + depName: string; + currentValue: string; + newValue: string; +} diff --git a/package.json b/package.json index 45f1fb0eace3ad..485e1c54760675 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,7 @@ "deepmerge": "4.3.1", "dequal": "2.0.3", "detect-indent": "6.1.0", + "diff": "5.2.0", "editorconfig": "2.0.0", "email-addresses": "5.0.0", "emoji-regex": "10.3.0", @@ -312,7 +313,6 @@ "callsite": "1.0.0", "common-tags": "1.8.2", "conventional-changelog-conventionalcommits": "7.0.2", - "diff": "5.2.0", "emojibase-data": "15.3.0", "eslint": "8.57.0", "eslint-formatter-gha": "1.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2702c92a4439d..d1d07c0f34be10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: detect-indent: specifier: 6.1.0 version: 6.1.0 + diff: + specifier: 5.2.0 + version: 5.2.0 editorconfig: specifier: 2.0.0 version: 2.0.0 @@ -511,9 +514,6 @@ importers: conventional-changelog-conventionalcommits: specifier: 7.0.2 version: 7.0.2 - diff: - specifier: 5.2.0 - version: 5.2.0 emojibase-data: specifier: 15.3.0 version: 15.3.0(emojibase@15.3.0)