mirror of
https://github.com/renovatebot/renovate.git
synced 2025-01-10 14:06:30 +00:00
Merge 70069e21aa
into 6aa5c4238f
This commit is contained in:
commit
dd5500ea4e
10 changed files with 389 additions and 7 deletions
|
@ -12,6 +12,7 @@ export const Categories = [
|
||||||
'dotnet',
|
'dotnet',
|
||||||
'elixir',
|
'elixir',
|
||||||
'golang',
|
'golang',
|
||||||
|
'haskell',
|
||||||
'helm',
|
'helm',
|
||||||
'iac',
|
'iac',
|
||||||
'java',
|
'java',
|
||||||
|
|
|
@ -44,6 +44,7 @@ import * as gleam from './gleam';
|
||||||
import * as gomod from './gomod';
|
import * as gomod from './gomod';
|
||||||
import * as gradle from './gradle';
|
import * as gradle from './gradle';
|
||||||
import * as gradleWrapper from './gradle-wrapper';
|
import * as gradleWrapper from './gradle-wrapper';
|
||||||
|
import * as haskellCabal from './haskell-cabal';
|
||||||
import * as helmRequirements from './helm-requirements';
|
import * as helmRequirements from './helm-requirements';
|
||||||
import * as helmValues from './helm-values';
|
import * as helmValues from './helm-values';
|
||||||
import * as helmfile from './helmfile';
|
import * as helmfile from './helmfile';
|
||||||
|
@ -150,6 +151,7 @@ api.set('gleam', gleam);
|
||||||
api.set('gomod', gomod);
|
api.set('gomod', gomod);
|
||||||
api.set('gradle', gradle);
|
api.set('gradle', gradle);
|
||||||
api.set('gradle-wrapper', gradleWrapper);
|
api.set('gradle-wrapper', gradleWrapper);
|
||||||
|
api.set('haskell-cabal', haskellCabal);
|
||||||
api.set('helm-requirements', helmRequirements);
|
api.set('helm-requirements', helmRequirements);
|
||||||
api.set('helm-values', helmValues);
|
api.set('helm-values', helmValues);
|
||||||
api.set('helmfile', helmfile);
|
api.set('helmfile', helmfile);
|
||||||
|
|
86
lib/modules/manager/haskell-cabal/extract.spec.ts
Normal file
86
lib/modules/manager/haskell-cabal/extract.spec.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import {
|
||||||
|
countPackageNameLength,
|
||||||
|
countPrecedingIndentation,
|
||||||
|
extractNamesAndRanges,
|
||||||
|
findExtents,
|
||||||
|
splitSingleDependency,
|
||||||
|
} from './extract';
|
||||||
|
|
||||||
|
describe('modules/manager/haskell-cabal/extract', () => {
|
||||||
|
describe('countPackageNameLength', () => {
|
||||||
|
it.each`
|
||||||
|
input | expected
|
||||||
|
${'-'} | ${null}
|
||||||
|
${'-j'} | ${null}
|
||||||
|
${'-H'} | ${null}
|
||||||
|
${'j-'} | ${null}
|
||||||
|
${'3-'} | ${null}
|
||||||
|
${'-3'} | ${null}
|
||||||
|
${'3'} | ${null}
|
||||||
|
${'æ'} | ${null}
|
||||||
|
${'æe'} | ${null}
|
||||||
|
${'j'} | ${1}
|
||||||
|
${'H'} | ${1}
|
||||||
|
${'0ad'} | ${3}
|
||||||
|
${'3d'} | ${2}
|
||||||
|
`('matches $input', ({ input, expected }) => {
|
||||||
|
const maybeIndex = countPackageNameLength(input);
|
||||||
|
expect(maybeIndex).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('countPrecedingIndentation()', () => {
|
||||||
|
it.each`
|
||||||
|
content | index | expected
|
||||||
|
${'\tbuild-depends: base\n\tother-field: hi'} | ${1} | ${1}
|
||||||
|
${' build-depends: base'} | ${1} | ${1}
|
||||||
|
${'a\tb'} | ${0} | ${0}
|
||||||
|
${'a\tb'} | ${2} | ${1}
|
||||||
|
${'a b'} | ${2} | ${1}
|
||||||
|
${' b'} | ${2} | ${2}
|
||||||
|
`(
|
||||||
|
'countPrecedingIndentation($content, $index)',
|
||||||
|
({ content, index, expected }) => {
|
||||||
|
expect(countPrecedingIndentation(content, index)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findExtents()', () => {
|
||||||
|
it.each`
|
||||||
|
content | indent | expected
|
||||||
|
${'a: b\n\tc: d'} | ${1} | ${10}
|
||||||
|
${'a: b'} | ${2} | ${4}
|
||||||
|
${'a: b\n\tc: d'} | ${2} | ${4}
|
||||||
|
${'a: b\n '} | ${2} | ${6}
|
||||||
|
${'a: b\n c: d\ne: f'} | ${1} | ${10}
|
||||||
|
`('findExtents($indent, $content)', ({ indent, content, expected }) => {
|
||||||
|
expect(findExtents(indent, content)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('splitSingleDependency()', () => {
|
||||||
|
it.each`
|
||||||
|
depLine | expectedName | expectedRange
|
||||||
|
${'base >=2 && <3'} | ${'base'} | ${'>=2 && <3'}
|
||||||
|
${'base >=2 && <3 '} | ${'base'} | ${'>=2 && <3'}
|
||||||
|
${'base>=2&&<3'} | ${'base'} | ${'>=2&&<3'}
|
||||||
|
${'base'} | ${'base'} | ${''}
|
||||||
|
`(
|
||||||
|
'splitSingleDependency($depLine)',
|
||||||
|
({ depLine, expectedName, expectedRange }) => {
|
||||||
|
const res = splitSingleDependency(depLine);
|
||||||
|
expect(res?.name).toBe(expectedName);
|
||||||
|
expect(res?.range).toBe(expectedRange);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractNamesAndRanges()', () => {
|
||||||
|
it('trims replaceString', () => {
|
||||||
|
const res = extractNamesAndRanges(' a , b ');
|
||||||
|
expect(res).toHaveLength(2);
|
||||||
|
expect(res[0].replaceString).toBe('a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
191
lib/modules/manager/haskell-cabal/extract.ts
Normal file
191
lib/modules/manager/haskell-cabal/extract.ts
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
import { regEx } from '../../../util/regex';
|
||||||
|
|
||||||
|
const buildDependsRegex = regEx(
|
||||||
|
/(?<buildDependsFieldName>build-depends[ \t]*:)/i,
|
||||||
|
);
|
||||||
|
function isNonASCII(str: string): boolean {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
if (str.charCodeAt(i) > 127) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countPackageNameLength(input: string): number | null {
|
||||||
|
if (input.length < 1 || isNonASCII(input)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!regEx(/^[A-Za-z0-9]/).test(input[0])) {
|
||||||
|
// Must start with letter or number
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let idx = 1;
|
||||||
|
while (idx < input.length) {
|
||||||
|
if (regEx(/[A-Za-z0-9-]/).test(input[idx])) {
|
||||||
|
idx++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!regEx(/[A-Za-z]/).test(input.slice(0, idx))) {
|
||||||
|
// Must contain a letter
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (idx - 1 < input.length && input[idx - 1] === '-') {
|
||||||
|
// Can't end in a hyphen
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabalDependency {
|
||||||
|
packageName: string;
|
||||||
|
currentValue: string;
|
||||||
|
replaceString: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find extents of field contents
|
||||||
|
*
|
||||||
|
* @param {number} indent -
|
||||||
|
* Indention level maintained within the block.
|
||||||
|
* Any indention lower than this means it's outside the field.
|
||||||
|
* Lines with this level or more are included in the field.
|
||||||
|
* @returns {number}
|
||||||
|
* Index just after the end of the block.
|
||||||
|
* Note that it may be after the end of the string.
|
||||||
|
*/
|
||||||
|
export function findExtents(indent: number, content: string): number {
|
||||||
|
let blockIdx: number = 0;
|
||||||
|
let mode: 'finding-newline' | 'finding-indention' = 'finding-newline';
|
||||||
|
for (;;) {
|
||||||
|
if (mode === 'finding-newline') {
|
||||||
|
while (content[blockIdx++] !== '\n') {
|
||||||
|
if (blockIdx >= content.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blockIdx >= content.length) {
|
||||||
|
return content.length;
|
||||||
|
}
|
||||||
|
mode = 'finding-indention';
|
||||||
|
} else {
|
||||||
|
let thisIndent = 0;
|
||||||
|
for (;;) {
|
||||||
|
if ([' ', '\t'].includes(content[blockIdx])) {
|
||||||
|
thisIndent += 1;
|
||||||
|
blockIdx++;
|
||||||
|
if (blockIdx >= content.length) {
|
||||||
|
return content.length;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mode = 'finding-newline';
|
||||||
|
blockIdx++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (thisIndent < indent) {
|
||||||
|
// go back to before the newline
|
||||||
|
for (;;) {
|
||||||
|
if (content[blockIdx--] === '\n') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blockIdx + 1;
|
||||||
|
}
|
||||||
|
mode = 'finding-newline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find indention level of build-depends
|
||||||
|
*
|
||||||
|
* @param {number} match -
|
||||||
|
* Search starts at this index, and proceeds backwards.
|
||||||
|
* @returns {number}
|
||||||
|
* Number of indention levels found before 'match'.
|
||||||
|
*/
|
||||||
|
export function countPrecedingIndentation(
|
||||||
|
content: string,
|
||||||
|
match: number,
|
||||||
|
): number {
|
||||||
|
let whitespaceIdx = match - 1;
|
||||||
|
let indent = 0;
|
||||||
|
while (whitespaceIdx >= 0 && [' ', '\t'].includes(content[whitespaceIdx])) {
|
||||||
|
indent += 1;
|
||||||
|
whitespaceIdx--;
|
||||||
|
}
|
||||||
|
return indent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find one 'build-depends' field name usage and its field value
|
||||||
|
*
|
||||||
|
* @returns {{buildDependsContent: string, lengthProcessed: number}}
|
||||||
|
* buildDependsContent:
|
||||||
|
* the contents of the field, excluding the field name and the colon.
|
||||||
|
*
|
||||||
|
* lengthProcessed:
|
||||||
|
* points to after the end of the field. Note that the field does _not_
|
||||||
|
* necessarily start at `content.length - lengthProcessed`.
|
||||||
|
*
|
||||||
|
* Returns null if no 'build-depends' field is found.
|
||||||
|
*/
|
||||||
|
export function findDepends(
|
||||||
|
content: string,
|
||||||
|
): { buildDependsContent: string; lengthProcessed: number } | null {
|
||||||
|
const matchObj = buildDependsRegex.exec(content);
|
||||||
|
if (matchObj === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indent = countPrecedingIndentation(content, matchObj.index);
|
||||||
|
const ourIdx: number =
|
||||||
|
matchObj.index + matchObj.groups!['buildDependsFieldName'].length;
|
||||||
|
const extent: number = findExtents(indent + 1, content.slice(ourIdx));
|
||||||
|
return {
|
||||||
|
buildDependsContent: content.slice(ourIdx, ourIdx + extent),
|
||||||
|
lengthProcessed: ourIdx + extent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a cabal single dependency into its constituent parts.
|
||||||
|
* The first part is the package name, an optional second part contains
|
||||||
|
* the version constraint.
|
||||||
|
*
|
||||||
|
* For example 'base == 3.2' would be split into 'base' and ' == 3.2'.
|
||||||
|
*
|
||||||
|
* @returns {{name: string, range: string}}
|
||||||
|
* Null if the trimmed string doesn't begin with a package name.
|
||||||
|
*/
|
||||||
|
export function splitSingleDependency(
|
||||||
|
input: string,
|
||||||
|
): { name: string; range: string } | null {
|
||||||
|
const match = countPackageNameLength(input);
|
||||||
|
if (match === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name: string = input.slice(0, match);
|
||||||
|
const range = input.slice(match).trim();
|
||||||
|
return { name, range };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractNamesAndRanges(content: string): CabalDependency[] {
|
||||||
|
const list = content.split(',');
|
||||||
|
const deps = [];
|
||||||
|
for (const untrimmedReplaceString of list) {
|
||||||
|
const replaceString = untrimmedReplaceString.trim();
|
||||||
|
const maybeNameRange = splitSingleDependency(replaceString);
|
||||||
|
if (maybeNameRange !== null) {
|
||||||
|
deps.push({
|
||||||
|
currentValue: maybeNameRange.range,
|
||||||
|
packageName: maybeNameRange.name,
|
||||||
|
replaceString,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deps;
|
||||||
|
}
|
33
lib/modules/manager/haskell-cabal/index.spec.ts
Normal file
33
lib/modules/manager/haskell-cabal/index.spec.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { extractPackageFile, getRangeStrategy } from '.';
|
||||||
|
|
||||||
|
describe('modules/manager/haskell-cabal/index', () => {
|
||||||
|
describe('extractPackageFile()', () => {
|
||||||
|
it.each`
|
||||||
|
content | expected
|
||||||
|
${'build-depends: base,'} | ${['base']}
|
||||||
|
${'build-depends:,other,other2'} | ${['other', 'other2']}
|
||||||
|
${'build-depends : base'} | ${['base']}
|
||||||
|
${'Build-Depends: base'} | ${['base']}
|
||||||
|
${'build-depends: a\nbuild-depends: b'} | ${['a', 'b']}
|
||||||
|
${'dependencies: base'} | ${[]}
|
||||||
|
`(
|
||||||
|
'extractPackageFile($content).deps.map(x => x.packageName)',
|
||||||
|
({ content, expected }) => {
|
||||||
|
expect(
|
||||||
|
extractPackageFile(content).deps.map((x) => x.packageName),
|
||||||
|
).toStrictEqual(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRangeStrategy()', () => {
|
||||||
|
it.each`
|
||||||
|
input | expected
|
||||||
|
${'auto'} | ${'widen'}
|
||||||
|
${'widen'} | ${'widen'}
|
||||||
|
${'replace'} | ${'replace'}
|
||||||
|
`('getRangeStrategy({ rangeStrategy: $input })', ({ input, expected }) => {
|
||||||
|
expect(getRangeStrategy({ rangeStrategy: input })).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
58
lib/modules/manager/haskell-cabal/index.ts
Normal file
58
lib/modules/manager/haskell-cabal/index.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import type { Category } from '../../../constants';
|
||||||
|
import type { RangeStrategy } from '../../../types';
|
||||||
|
import { HackageDatasource } from '../../datasource/hackage';
|
||||||
|
import * as pvpVersioning from '../../versioning/pvp';
|
||||||
|
import type {
|
||||||
|
PackageDependency,
|
||||||
|
PackageFileContent,
|
||||||
|
RangeConfig,
|
||||||
|
} from '../types';
|
||||||
|
import type { CabalDependency } from './extract';
|
||||||
|
import { extractNamesAndRanges, findDepends } from './extract';
|
||||||
|
|
||||||
|
export const defaultConfig = {
|
||||||
|
fileMatch: ['\\.cabal$'],
|
||||||
|
pinDigests: false,
|
||||||
|
versioning: pvpVersioning.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const categories: Category[] = ['haskell'];
|
||||||
|
|
||||||
|
export const supportedDatasources = [HackageDatasource.id];
|
||||||
|
|
||||||
|
export function extractPackageFile(content: string): PackageFileContent {
|
||||||
|
const deps = [];
|
||||||
|
let current = content;
|
||||||
|
for (;;) {
|
||||||
|
const maybeContent = findDepends(current);
|
||||||
|
if (maybeContent === null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const cabalDeps: CabalDependency[] = extractNamesAndRanges(
|
||||||
|
maybeContent.buildDependsContent,
|
||||||
|
);
|
||||||
|
for (const cabalDep of cabalDeps) {
|
||||||
|
const dep: PackageDependency = {
|
||||||
|
depName: cabalDep.packageName,
|
||||||
|
currentValue: cabalDep.currentValue,
|
||||||
|
datasource: HackageDatasource.id,
|
||||||
|
packageName: cabalDep.packageName,
|
||||||
|
versioning: 'pvp',
|
||||||
|
replaceString: cabalDep.replaceString.trim(),
|
||||||
|
autoReplaceStringTemplate: '{{{depName}}} {{{newValue}}}',
|
||||||
|
};
|
||||||
|
deps.push(dep);
|
||||||
|
}
|
||||||
|
current = current.slice(maybeContent.lengthProcessed);
|
||||||
|
}
|
||||||
|
return { deps };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRangeStrategy({
|
||||||
|
rangeStrategy,
|
||||||
|
}: RangeConfig): RangeStrategy {
|
||||||
|
if (rangeStrategy === 'auto') {
|
||||||
|
return 'widen';
|
||||||
|
}
|
||||||
|
return rangeStrategy;
|
||||||
|
}
|
10
lib/modules/manager/haskell-cabal/readme.md
Normal file
10
lib/modules/manager/haskell-cabal/readme.md
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
Supports dependency extraction from `build-depends` fields in [Cabal package description files](https://cabal.readthedocs.io/en/3.12/cabal-package-description-file.html#pkg-field-build-depends).
|
||||||
|
They use the extension `.cabal`, and are used with the [Haskell programming language](https://www.haskell.org/).
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
|
||||||
|
- The dependencies of all components are mushed together in one big list.
|
||||||
|
- Fields like `pkgconfig-depends` and `build-tool-depends` are not handled.
|
||||||
|
- The default PVP versioning is [subject to limitations](../../versioning/pvp/index.md).
|
||||||
|
|
||||||
|
If you need to change the versioning format, read the [versioning](../../versioning/index.md) documentation to learn more.
|
|
@ -141,12 +141,12 @@ describe('modules/versioning/pvp/index', () => {
|
||||||
describe('.getNewValue(newValueConfig)', () => {
|
describe('.getNewValue(newValueConfig)', () => {
|
||||||
it.each`
|
it.each`
|
||||||
currentValue | newVersion | rangeStrategy | expected
|
currentValue | newVersion | rangeStrategy | expected
|
||||||
${'>=1.0 && <1.1'} | ${'1.1'} | ${'auto'} | ${'>=1.0 && <1.2'}
|
${'>=1.0 && <1.1'} | ${'1.1'} | ${'widen'} | ${'>=1.0 && <1.2'}
|
||||||
${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'auto'} | ${null}
|
${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'widen'} | ${null}
|
||||||
${'>=1.0 && <1.1'} | ${'1.2.3'} | ${'update-lockfile'} | ${null}
|
${'>=1.0 && <1.1'} | ${'1.2.3'} | ${'update-lockfile'} | ${null}
|
||||||
${'gibberish'} | ${'1.2.3'} | ${'auto'} | ${null}
|
${'gibberish'} | ${'1.2.3'} | ${'widen'} | ${null}
|
||||||
${'>=1.0 && <1.1'} | ${'0.9'} | ${'auto'} | ${null}
|
${'>=1.0 && <1.1'} | ${'0.9'} | ${'widen'} | ${null}
|
||||||
${'>=1.0 && <1.1'} | ${''} | ${'auto'} | ${null}
|
${'>=1.0 && <1.1'} | ${''} | ${'widen'} | ${null}
|
||||||
`(
|
`(
|
||||||
'pvp.getNewValue({currentValue: "$currentValue", newVersion: "$newVersion", rangeStrategy: "$rangeStrategy"}) === $expected',
|
'pvp.getNewValue({currentValue: "$currentValue", newVersion: "$newVersion", rangeStrategy: "$rangeStrategy"}) === $expected',
|
||||||
({ currentValue, newVersion, rangeStrategy, expected }) => {
|
({ currentValue, newVersion, rangeStrategy, expected }) => {
|
||||||
|
|
|
@ -9,7 +9,7 @@ export const id = 'pvp';
|
||||||
export const displayName = 'Package Versioning Policy (Haskell)';
|
export const displayName = 'Package Versioning Policy (Haskell)';
|
||||||
export const urls = ['https://pvp.haskell.org'];
|
export const urls = ['https://pvp.haskell.org'];
|
||||||
export const supportsRanges = true;
|
export const supportsRanges = true;
|
||||||
export const supportedRangeStrategies: RangeStrategy[] = ['auto'];
|
export const supportedRangeStrategies: RangeStrategy[] = ['widen'];
|
||||||
|
|
||||||
const digitsAndDots = regEx(/^[\d.]+$/);
|
const digitsAndDots = regEx(/^[\d.]+$/);
|
||||||
|
|
||||||
|
@ -112,7 +112,7 @@ function getNewValue({
|
||||||
newVersion,
|
newVersion,
|
||||||
rangeStrategy,
|
rangeStrategy,
|
||||||
}: NewValueConfig): string | null {
|
}: NewValueConfig): string | null {
|
||||||
if (rangeStrategy !== 'auto') {
|
if (rangeStrategy !== 'widen') {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ rangeStrategy, currentValue, newVersion },
|
{ rangeStrategy, currentValue, newVersion },
|
||||||
`PVP can't handle this range strategy.`,
|
`PVP can't handle this range strategy.`,
|
||||||
|
|
|
@ -48,6 +48,7 @@ export const CategoryNames: Record<Category, string> = {
|
||||||
dotnet: '.NET',
|
dotnet: '.NET',
|
||||||
elixir: 'Elixir',
|
elixir: 'Elixir',
|
||||||
golang: 'Go',
|
golang: 'Go',
|
||||||
|
haskell: 'Haskell',
|
||||||
helm: 'Helm',
|
helm: 'Helm',
|
||||||
iac: 'Infrastructure as Code',
|
iac: 'Infrastructure as Code',
|
||||||
java: 'Java',
|
java: 'Java',
|
||||||
|
|
Loading…
Reference in a new issue