diff --git a/.changeset/witty-pans-wave.md b/.changeset/witty-pans-wave.md new file mode 100644 index 00000000..ea721fe7 --- /dev/null +++ b/.changeset/witty-pans-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/v1-56-0-migration-recipe': minor +--- + +Add Backstage 1.56.0 migration recipe diff --git a/codemods/v1.56.0/README.md b/codemods/v1.56.0/README.md new file mode 100644 index 00000000..cd98487f --- /dev/null +++ b/codemods/v1.56.0/README.md @@ -0,0 +1,66 @@ +# v1.56.0-migration-recipe + +One-shot migration recipe for upgrading a Backstage app to 1.56.0. Runs every bundled local transform script through one workflow. + +## What it runs + +| # | Local script | Scope | +| --- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| 1 | [`scripts/migrate-catalog-filters-to-query.ts`](scripts/migrate-catalog-filters-to-query.ts) | Adds `getCatalogQuery()` next to deprecated `getCatalogFilters()` on custom `EntityFilter` | +| 2 | [`scripts/remove-atlassian-username-resolver.ts`](scripts/remove-atlassian-username-resolver.ts) | Replaces the removed Atlassian `usernameMatchingUserEntityName` sign-in resolver | +| 3 | [`scripts/remove-atlassian-username-resolver-yaml.ts`](scripts/remove-atlassian-username-resolver-yaml.ts) | Flags the removed Atlassian resolver in `app-config` YAML (TODO only) | +| 4 | [`scripts/remove-total-items-option.ts`](scripts/remove-total-items-option.ts) | Removes the deprecated-and-ignored `totalItems` option of `StreamEntitiesRequest` | +| 5 | [`scripts/move-bui-menusection-disabled-keys.ts`](scripts/move-bui-menusection-disabled-keys.ts) | Moves BUI `MenuSection` `disabledKeys` to the enclosing `Menu` (or `MenuItem` `isDisabled`) | + +## Usage + +```bash +# Dry-run first + +yarn dlx codemod@latest run @backstage/v1-56-0-migration-recipe \ + --target /path/to/backstage-app \ + --dry-run + +# Apply + +yarn dlx codemod@latest run @backstage/v1-56-0-migration-recipe \ + --target /path/to/backstage-app +``` + +## Running a single script + +To run one transform, copy its `js-ast-grep` step from `workflow.yaml` into a minimal workflow, preserving `js_file`, `language`, selectors, and any step options. Point `js_file` at the bundled script, then run `codemod workflow run -w --target /path/to/backstage-app --dry-run` before applying. Keep related passes in recipe order when selecting more than one step. + +## AI behavior and review + +None. The v1.56.0 codemods are deterministic AST transforms and do not expose an `aiFixup` parameter. + +- Several transforms mark unresolved intent with `TODO(backstage-codemod)` comments (computed filter returns, the Atlassian resolver choice, dynamic `disabledKeys`). After running the recipe, grep for `TODO(backstage-codemod)` and resolve each marker manually. +- `EntityFilter.getCatalogFilters()` is never deleted: `EntityListProvider` still honors it as a fallback for filters without `getCatalogQuery()`. +- The Atlassian resolver replacement is mechanical (`emailLocalPartMatchingUserEntityName`), but the two replacement resolvers differ in data requirements; review each TODO before applying. + +### Manual-only migration work (out of scope) + +The following Backstage 1.56.0 changes require manual migration decisions and are not covered by this recipe: + +- `FilterPredicate` root must be an object (`@backstage/filter-predicates`); the catalog backend rejects primitive roots. Use `AnyFilterPredicate` where primitives are intended. +- Backend instance IDs (BREAKING for producers): custom backend implementations and mocks must supply a stable unique ID; TypeScript flags the missing member. `createBackend` gains an optional `instanceId` option. +- Incremental ingestion `ingestion_mark_entities` upsert (`source_key` column, `ref` → `entity_ref`): DB schema migration that is not rolling/zero-downtime upgradable. +- TechDocs engine abstraction: `techdocs` generator key alias, `serve:mkdocs`, and `--mkdocs-parameter-*` warnings are CLI/config concerns; the MkDocs default is unchanged and the alias still works. +- GitLab action raw token outputs deprecated: adopt `variableKey` per template as a security choice. +- Checkbox/Switch programmatic label clicks no longer toggle: test behavior; target the input and use full pointer interaction. +- Builtin catalog filter behavior changes (`EntityTagFilter` all-tags, `EntityUserFilter.starred` full refs, `EntityOrderFilter` exclusions, `EntityListProvider` on `streamEntities`): semantics and test-mock adjustments with no mechanical rewrite. +- `--bui-bg-app` color-scheme follow and the new `theme` CSS cascade layer: behavioral/additive; apps with their own `theme` layer should verify ordering. +- New CLI modules (catalog/scaffolder/search), Featured Templates widget, org `UserAvatar`, and TechDocs `NotFoundErrorPage`/`withoutFeedbackLink` are purely additive. + +## Development + +- `yarn test` runs fixture suites, script type checks, Node regression suites, and workflow validation via `run-recipe-tests`. + +The package-root `tsconfig.json` checks production scripts and excludes `tests/`; repository-wide `yarn lint` includes source type checking. The `tests/**/tsconfig.json` files also check scripts, not fixture input/output trees. + +## Notes + +- The Atlassian resolver change spans TypeScript and YAML. The recipe ships two script entrypoints (`remove-atlassian-username-resolver.ts` for TS, `remove-atlassian-username-resolver-yaml.ts` for `app-config`), mirroring the per-language script convention used by earlier recipes; the recipe runs both as one logical step. +- This recipe only orchestrates Backstage 1.56.0 codemods. Run earlier release recipes first when upgrading from older versions. +- Each step is a `js-ast-grep` workflow action using a bundled local script; no separate registry packages are required. diff --git a/codemods/v1.56.0/codemod.yaml b/codemods/v1.56.0/codemod.yaml new file mode 100644 index 00000000..b67ae277 --- /dev/null +++ b/codemods/v1.56.0/codemod.yaml @@ -0,0 +1,19 @@ +schema_version: '1.0' + +name: '@backstage/v1-56-0-migration-recipe' +version: '0.1.0' +description: 'Backstage 1.56.0: Migration recipe that runs every @backstage v1.56.0 codemod script in a safe order.' +author: 'Paul Schultz' +license: 'Apache-2.0' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts', 'yaml'] + +keywords: ['backstage', 'migration', 'recipe', 'workflow', 'upgrade', '1.56.0'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/v1.56.0/package.json b/codemods/v1.56.0/package.json new file mode 100644 index 00000000..2f52f167 --- /dev/null +++ b/codemods/v1.56.0/package.json @@ -0,0 +1,17 @@ +{ + "name": "@backstage/v1-56-0-migration-recipe", + "version": "0.1.0", + "description": "Backstage 1.56.0: Migration recipe that runs every Backstage 1.56.0 codemod script in a safe order.", + "type": "module", + "scripts": { + "test": "run-recipe-tests" + }, + "dependencies": { + "@jssg/utils": "patch:@jssg/utils@npm%3A0.0.11#~/.yarn/patches/@jssg-utils-npm-0.0.11-19369c37c8.patch" + }, + "devDependencies": { + "@backstage/scripts": "workspace:*", + "@codemod.com/jssg-types": "1.6.3", + "codemod": "1.18.3" + } +} diff --git a/codemods/v1.56.0/scripts/migrate-catalog-filters-to-query.ts b/codemods/v1.56.0/scripts/migrate-catalog-filters-to-query.ts new file mode 100644 index 00000000..9f95db66 --- /dev/null +++ b/codemods/v1.56.0/scripts/migrate-catalog-filters-to-query.ts @@ -0,0 +1,262 @@ +import { importBindingOf } from '@jssg/utils/javascript/bindings' +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-catalog-filters-to-query') + +const CATALOG_REACT_SOURCE = '@backstage/plugin-catalog-react' +const ENTITY_FILTER_TYPE = 'EntityFilter' +const FILTERS_METHOD = 'getCatalogFilters' +const QUERY_METHOD = 'getCatalogQuery' + +const TODO_MARKER = 'TODO(backstage-codemod): translate getCatalogFilters to a filter predicate' + +type FilterContext = 'class' | 'object' + +function isEntityFilterTypeIdentifier(node: SgNode | null): boolean { + if (!node?.is('type_identifier') || node.text() !== ENTITY_FILTER_TYPE) { + return false + } + + const binding = importBindingOf(node) + return binding?.source === CATALOG_REACT_SOURCE && binding.importedName === ENTITY_FILTER_TYPE +} + +function classImplementsEntityFilter(classNode: SgNode): boolean { + const heritage = classNode.find({ rule: { kind: 'class_heritage' } }) + if (!heritage) { + return false + } + + const typeIdentifiers = heritage.findAll({ rule: { kind: 'type_identifier' } }) + return typeIdentifiers.some((typeNode) => isEntityFilterTypeIdentifier(typeNode)) +} + +function declaratorIsEntityFilterTyped(declarator: SgNode): boolean { + const typeAnnotation = declarator.find({ rule: { kind: 'type_annotation' } }) + if (!typeAnnotation) { + return false + } + + const typeIdentifier = typeAnnotation.find({ rule: { kind: 'type_identifier' } }) + return isEntityFilterTypeIdentifier(typeIdentifier) +} + +/** + * Resolves the context of a `getCatalogFilters` method definition: either a + * member of a class implementing `EntityFilter`, or a property of an object + * literal assigned to an `EntityFilter`-typed binding. + */ +function getFilterContext(method: SgNode): FilterContext | null { + const parent = method.parent() + if (parent?.is('class_body')) { + const classNode = parent.parent() + if (classNode && classImplementsEntityFilter(classNode)) { + return 'class' + } + return null + } + + if (parent?.is('object')) { + const grandParent = parent.parent() + if (grandParent?.is('variable_declarator') && declaratorIsEntityFilterTyped(grandParent)) { + return 'object' + } + return null + } + + return null +} + +function isLiteralValueNode(node: SgNode): boolean { + const kind = node.kind() + return ( + kind === 'string' || + kind === 'number' || + kind === 'true' || + kind === 'false' || + kind === 'null' || + kind === 'undefined' + ) +} + +function arrayElements(arrayNode: SgNode): SgNode[] { + return arrayNode.children().filter((child) => ![',', '[', ']'].includes(child.kind())) +} + +/** Builds `{ key: value }` or `{ $any: [{ key: v1 }, { key: v2 }] }` for one pair. */ +function pairCondition(pair: SgNode): string | null { + const keyNode = pair.field('key') + const valueNode = pair.field('value') + if (!keyNode || !valueNode) { + return null + } + + const keyKind = keyNode.kind() + if (keyKind !== 'string' && keyKind !== 'property_identifier') { + return null + } + const keyText = keyNode.text() + + if (valueNode.is('array')) { + const elements = arrayElements(valueNode) + if (elements.length === 0 || !elements.every((element) => isLiteralValueNode(element))) { + return null + } + + if (elements.length === 1) { + return `{ ${keyText}: ${elements[0]?.text()} }` + } + const conditions = elements.map((element) => `{ ${keyText}: ${element.text()} }`) + return `{ $any: [${conditions.join(', ')}] }` + } + + if (!isLiteralValueNode(valueNode)) { + return null + } + + return `{ ${keyText}: ${valueNode.text()} }` +} + +/** Builds a single entry predicate: one condition collapses, several join with `$all`. */ +function entryPredicate(entry: SgNode): string | null { + if (!entry.is('object')) { + return null + } + + const pairs = entry.children().filter((child) => child.is('pair')) + if (pairs.length === 0) { + return null + } + + const conditions: string[] = [] + for (const pair of pairs) { + const condition = pairCondition(pair) + if (condition === null) { + return null + } + conditions.push(condition) + } + + if (conditions.length === 1) { + return conditions[0] ?? null + } + return `{ $all: [${conditions.join(', ')}] }` +} + +/** + * Translates a literal `CatalogFilters[]` return value into a filter predicate + * expression, or null when the return is computed/non-literal. + */ +function buildQueryPredicate(returnStatement: SgNode): string | null { + const argument = returnStatement.children().find((child) => !['return', ';'].includes(child.kind())) + if (!argument?.is('array')) { + return null + } + + const entries = arrayElements(argument) + if (entries.length === 0) { + return 'undefined' + } + + const predicates: string[] = [] + for (const entry of entries) { + const predicate = entryPredicate(entry) + if (predicate === null) { + return null + } + predicates.push(predicate) + } + + if (predicates.length === 1) { + return predicates[0] ?? null + } + return `{ $any: [${predicates.join(', ')}] }` +} + +function getSingleReturnStatement(method: SgNode): SgNode | null { + const body = method.children().find((child) => child.is('statement_block')) + if (!body) { + return null + } + + const returnStatements = body.findAll({ rule: { kind: 'return_statement' } }) + if (returnStatements.length !== 1) { + return null + } + + return returnStatements[0] ?? null +} + +function lineIndentOf(source: string, index: number): string { + const lineStart = source.lastIndexOf('\n', index - 1) + 1 + const line = source.slice(lineStart, index) + const match = line.match(/^[ \t]*/) + return match?.[0] ?? '' +} + +function buildQueryMethod(indent: string, predicate: string | null): string { + if (predicate === null) { + return [ + `${indent}${QUERY_METHOD}() {`, + `${indent} // ${TODO_MARKER}`, + `${indent} return undefined;`, + `${indent}}`, + ].join('\n') + } + + return [`${indent}${QUERY_METHOD}() {`, `${indent} return ${predicate};`, `${indent}}`].join('\n') +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const source = rootNode.text() + + // Files that already define getCatalogQuery are considered migrated. + if (source.includes(QUERY_METHOD)) { + return null + } + + const edits: Edit[] = [] + + const methods = rootNode.findAll({ + rule: { + kind: 'method_definition', + has: { field: 'name', kind: 'property_identifier', regex: `^${FILTERS_METHOD}$` }, + }, + }) + + for (const method of methods) { + const context = getFilterContext(method) + if (!context) { + continue + } + + const returnStatement = getSingleReturnStatement(method) + const predicate = returnStatement ? buildQueryPredicate(returnStatement) : null + const indent = lineIndentOf(source, method.range().start.index) + + const insertionStart = method.range().end.index + edits.push({ + startPos: insertionStart, + endPos: insertionStart, + insertedText: `\n\n${buildQueryMethod(indent, predicate)}`, + }) + + migrationMetric.increment({ + action: 'getCatalogQuery-added', + context, + result: predicate === null ? 'todo' : 'literal', + }) + } + + if (edits.length === 0) { + return null + } + + const result = await Promise.resolve(rootNode.commitEdits(edits)) + return result +} + +export default transform diff --git a/codemods/v1.56.0/scripts/move-bui-menusection-disabled-keys.ts b/codemods/v1.56.0/scripts/move-bui-menusection-disabled-keys.ts new file mode 100644 index 00000000..31e4da8d --- /dev/null +++ b/codemods/v1.56.0/scripts/move-bui-menusection-disabled-keys.ts @@ -0,0 +1,294 @@ +import { importBindingOf } from '@jssg/utils/javascript/bindings' +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('move-bui-menusection-disabled-keys') + +const UI_SOURCE = '@backstage/ui' +const MENU_SECTION = 'MenuSection' +const MENU = 'Menu' +const MENU_ITEM = 'MenuItem' +const DISABLED_KEYS = 'disabledKeys' +const IS_DISABLED = 'isDisabled' + +const TODO_MARKER = 'TODO(backstage-codemod)' + +type UiComponent = 'MenuSection' | 'Menu' | 'MenuItem' + +function lineIndentOf(source: string, index: number): string { + const lineStart = source.lastIndexOf('\n', index - 1) + 1 + const line = source.slice(lineStart, index) + const match = line.match(/^[ \t]*/) + return match?.[0] ?? '' +} + +function resolveUiComponent(nameNode: SgNode | null): UiComponent | null { + if (!nameNode?.is('identifier')) { + return null + } + + const binding = importBindingOf(nameNode) + if (!binding || binding.source !== UI_SOURCE) { + return null + } + + if (binding.importedName === MENU_SECTION || binding.importedName === MENU || binding.importedName === MENU_ITEM) { + return binding.importedName + } + + return null +} + +function getOpeningElement(el: SgNode): SgNode | null { + if (el.is('jsx_self_closing_element')) { + return el + } + if (el.is('jsx_element')) { + const opening = el.child(0) + return opening?.is('jsx_opening_element') ? opening : null + } + return null +} + +function getComponentNameNode(opening: SgNode): SgNode | null { + return opening.child(1) ?? null +} + +function getAttributes(opening: SgNode): SgNode[] { + return opening.children().filter((child) => child.is('jsx_attribute')) +} + +function getAttrName(attr: SgNode): string | null { + const propId = attr.find({ rule: { kind: 'property_identifier' } }) + return propId ? propId.text() : null +} + +function findAttribute(opening: SgNode, name: string): SgNode | null { + return getAttributes(opening).find((attr) => getAttrName(attr) === name) ?? null +} + +/** Inner expression text of a JSX attribute value, e.g. `['delete']`. */ +function getAttrValueText(attr: SgNode): string | null { + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + + const inner = expr.children().filter((child) => child.kind() !== '{' && child.kind() !== '}') + if (inner.length !== 1) { + return null + } + + return inner[0]?.text() ?? null +} + +/** String literal keys of `disabledKeys={['a', 'b']}`, or null when dynamic. */ +function getLiteralKeys(attr: SgNode): string[] | null { + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + + const arrayNode = expr.find({ rule: { kind: 'array' } }) + if (!arrayNode) { + return null + } + + const elements = arrayNode.children().filter((child) => child.is('string')) + const totalCount = arrayNode.children().filter((child) => ![',', '[', ']'].includes(child.kind())).length + if (elements.length !== totalCount) { + return null + } + + return elements.map((element) => unquote(element.text())) +} + +function removeJsxAttribute(attrNode: SgNode, fullSource: string): Edit { + let startPos = attrNode.range().start.index + const endPos = attrNode.range().end.index + + while (startPos > 0 && fullSource.charAt(startPos - 1) === ' ') { + startPos -= 1 + } + + return { + startPos, + endPos, + insertedText: '', + } +} + +function insertPropBeforeClosingTag(opening: SgNode, propText: string): Edit | null { + const text = opening.text() + const selfClosing = text.endsWith('/>') + const insertPos = opening.range().end.index - (selfClosing ? 2 : 1) + if (insertPos < opening.range().start.index) { + return null + } + + return { + startPos: insertPos, + endPos: insertPos, + insertedText: ` ${propText}`, + } +} + +function unquote(text: string): string { + if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) { + return text.slice(1, -1) + } + + return text +} + +function insertJsxCommentBefore(element: SgNode, comment: string, source: string): Edit { + const startPos = element.range().start.index + const indent = lineIndentOf(source, startPos) + return { + startPos, + endPos: startPos, + insertedText: `{/* ${comment} */}\n${indent}`, + } +} + +/** Nearest enclosing JSX element resolving to the given UI component. */ +function findEnclosingElement(element: SgNode, component: UiComponent): SgNode | null { + for (const ancestor of element.ancestors()) { + if (!ancestor.is('jsx_element') && !ancestor.is('jsx_self_closing_element')) { + continue + } + + const opening = getOpeningElement(ancestor) + const nameNode = opening ? getComponentNameNode(opening) : null + if (resolveUiComponent(nameNode) === component) { + return ancestor + } + } + + return null +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const source = rootNode.text() + const edits: Edit[] = [] + const menusAlreadyHandled = new Set() + + const sectionElements = rootNode + .findAll({ rule: { any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }] } }) + .filter((element) => { + const opening = getOpeningElement(element) + const nameNode = opening ? getComponentNameNode(opening) : null + return resolveUiComponent(nameNode) === MENU_SECTION + }) + + for (const section of sectionElements) { + const sectionOpening = getOpeningElement(section) + if (!sectionOpening) { + continue + } + + const disabledKeysAttr = findAttribute(sectionOpening, DISABLED_KEYS) + if (!disabledKeysAttr) { + continue // Already migrated. + } + + const valueText = getAttrValueText(disabledKeysAttr) + const literalKeys = getLiteralKeys(disabledKeysAttr) + + // 1. Remove disabledKeys from the MenuSection element. + edits.push(removeJsxAttribute(disabledKeysAttr, source)) + + // 2. Hoist to the nearest enclosing Menu when one exists. + const menuElement = findEnclosingElement(section, MENU) + if (menuElement) { + const menuOpening = getOpeningElement(menuElement) + const menuAlreadyDisabled = + menuOpening !== null && + (findAttribute(menuOpening, DISABLED_KEYS) !== null || menusAlreadyHandled.has(menuElement.id())) + + if (menuOpening && valueText !== null && !menuAlreadyDisabled) { + const insert = insertPropBeforeClosingTag(menuOpening, `${DISABLED_KEYS}={${valueText}}`) + if (insert) { + edits.push(insert) + menusAlreadyHandled.add(menuElement.id()) + migrationMetric.increment({ action: 'disabledKeys-hoisted-to-menu' }) + continue + } + } + + // The Menu cannot take another disabledKeys prop; keep the original for review. + edits.push( + insertJsxCommentBefore( + section, + `${TODO_MARKER}: Menu already has disabledKeys; merge ${DISABLED_KEYS}={${valueText ?? '…'}} manually`, + source, + ), + ) + migrationMetric.increment({ action: 'todo-manual-review', reason: 'menu-already-disabled' }) + continue + } + + // 3. No enclosing Menu: disable the literal-keyed MenuItems directly. + if (literalKeys !== null) { + const keySet = new Set(literalKeys) + let disabledCount = 0 + + const menuItems = section.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const item of menuItems) { + const itemOpening = getOpeningElement(item) + const itemNameNode = itemOpening ? getComponentNameNode(itemOpening) : null + if (resolveUiComponent(itemNameNode) !== MENU_ITEM || !itemOpening) { + continue + } + + const keyAttr = findAttribute(itemOpening, 'key') + const keyText = keyAttr?.find({ rule: { kind: 'string' } })?.text() + if (!keyAttr || keyText === undefined || !keySet.has(unquote(keyText))) { + continue + } + + if (findAttribute(itemOpening, IS_DISABLED)) { + continue + } + + const insert = insertPropBeforeClosingTag(itemOpening, IS_DISABLED) + if (insert) { + edits.push(insert) + disabledCount += 1 + migrationMetric.increment({ action: 'menuItem-isDisabled-set' }) + } + } + + if (disabledCount === 0) { + migrationMetric.increment({ action: 'todo-manual-review', reason: 'no-matching-menuitem' }) + } + continue + } + + // 4. Dynamic keys without a Menu: leave a TODO carrying the original prop. + edits.push( + insertJsxCommentBefore( + section, + `${TODO_MARKER}: MenuSection no longer accepts ${DISABLED_KEYS}; set ${IS_DISABLED} on the MenuItem elements or ${DISABLED_KEYS} on the Menu. Original: ${DISABLED_KEYS}={${valueText ?? '…'}}`, + source, + ), + ) + migrationMetric.increment({ action: 'todo-manual-review', reason: 'dynamic-keys' }) + } + + if (edits.length === 0) { + return null + } + + const result = await Promise.resolve(rootNode.commitEdits(edits)) + return result +} + +export default transform diff --git a/codemods/v1.56.0/scripts/remove-atlassian-username-resolver-yaml.ts b/codemods/v1.56.0/scripts/remove-atlassian-username-resolver-yaml.ts new file mode 100644 index 00000000..6ab89862 --- /dev/null +++ b/codemods/v1.56.0/scripts/remove-atlassian-username-resolver-yaml.ts @@ -0,0 +1,121 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type YAML from 'codemod:ast-grep/langs/yaml' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('remove-atlassian-username-resolver-yaml') + +const REMOVED_RESOLVER = 'usernameMatchingUserEntityName' +const ATLASSIAN_PROVIDER_KEY = 'atlassian' +const RESOLVER_KEY = 'resolver' + +const TODO_MARKER = 'TODO(backstage-codemod)' +const TODO_LINE = `# ${TODO_MARKER}: replace usernameMatchingUserEntityName with emailLocalPartMatchingUserEntityName or emailMatchingUserEntityProfileEmail` + +function normalizeKey(text: string): string { + const trimmed = text.trim() + + if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) { + return trimmed.slice(1, -1) + } + + return trimmed +} + +function getPairKeyName(pair: SgNode): string | null { + const keyNode = pair.field('key') + if (!keyNode) { + return null + } + + return normalizeKey(keyNode.text()) +} + +function getPairValueText(pair: SgNode): string | null { + const valueNode = pair.field('value') + if (!valueNode) { + return null + } + + return valueNode.text().trim() +} + +/** True when the pair sits somewhere below an `atlassian:` mapping key. */ +function isUnderAtlassianProvider(pair: SgNode): boolean { + for (const ancestor of pair.ancestors()) { + if (!ancestor.is('block_mapping_pair') && !ancestor.is('flow_pair')) { + continue + } + + if (getPairKeyName(ancestor) === ATLASSIAN_PROVIDER_KEY) { + return true + } + } + + return false +} + +function lineStartOf(source: string, index: number): number { + return source.lastIndexOf('\n', index - 1) + 1 +} + +function lineIndentOf(source: string, index: number): string { + const line = source.slice(lineStartOf(source, index), index) + const match = line.match(/^[ \t]*/) + return match?.[0] ?? '' +} + +function insertTodoAbove(pair: SgNode, source: string, edits: Edit[]): void { + const start = pair.range().start.index + const lineStart = lineStartOf(source, start) + + // Idempotency: skip when the previous non-empty line already carries the marker. + const previousLineEnd = source.lastIndexOf('\n', Math.max(lineStart - 2, 0)) + const previousLine = source.slice(previousLineEnd + 1, lineStart) + if (previousLine.includes(TODO_MARKER)) { + return + } + + const indent = lineIndentOf(source, start) + edits.push({ + startPos: lineStart, + endPos: lineStart, + insertedText: `${indent}${TODO_LINE}\n`, + }) +} + +/** + * Flags `resolver: usernameMatchingUserEntityName` under the Atlassian auth + * provider. The choice between the two email resolvers is a data-alignment + * decision, so the value is never rewritten here. + */ +const transform: Codemod = async (root) => { + const rootNode = root.root() + const source = rootNode.text() + const edits: Edit[] = [] + + for (const pair of rootNode.findAll({ rule: { kind: 'block_mapping_pair' } })) { + if (getPairKeyName(pair) !== RESOLVER_KEY) { + continue + } + + if (getPairValueText(pair) !== REMOVED_RESOLVER) { + continue + } + + if (!isUnderAtlassianProvider(pair)) { + continue + } + + insertTodoAbove(pair, source, edits) + migrationMetric.increment({ action: 'resolver-flagged' }) + } + + if (edits.length === 0) { + return null + } + + const result = await Promise.resolve(rootNode.commitEdits(edits)) + return result +} + +export default transform diff --git a/codemods/v1.56.0/scripts/remove-atlassian-username-resolver.ts b/codemods/v1.56.0/scripts/remove-atlassian-username-resolver.ts new file mode 100644 index 00000000..753573dd --- /dev/null +++ b/codemods/v1.56.0/scripts/remove-atlassian-username-resolver.ts @@ -0,0 +1,325 @@ +import { importBindingOf } from '@jssg/utils/javascript/bindings' +import { addImport } from '@jssg/utils/javascript/imports' +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('remove-atlassian-username-resolver') + +const ATLASSIAN_SOURCE = '@backstage/plugin-auth-backend-module-atlassian-provider' +const REMOVED_RESOLVER = 'usernameMatchingUserEntityName' +const REMOVED_NAMESPACE = 'atlassianSignInResolvers' +const REPLACEMENT_RESOLVER = 'emailLocalPartMatchingUserEntityName' + +const TODO_MARKER = 'TODO(backstage-codemod)' +const TODO_LINE = `// ${TODO_MARKER}: consider emailMatchingUserEntityProfileEmail instead` + +/** + * The removed resolver collection reaches adopter code in two import shapes: + * `import { atlassianSignInResolvers } from '...'` (named export) and + * `import * as atlassianSignInResolvers from '...'` (namespace). + */ +interface NamespaceBinding { + localName: string + form: 'named' | 'namespace' +} + +function namespaceBindingOf(node: SgNode): NamespaceBinding | null { + if (!node.is('identifier')) { + return null + } + + const binding = importBindingOf(node) + if (!binding || binding.source !== ATLASSIAN_SOURCE) { + return null + } + + if (binding.importedName === REMOVED_NAMESPACE) { + return { localName: binding.localName, form: 'named' } + } + + if (binding.importedName === '*') { + return { localName: binding.localName, form: 'namespace' } + } + + return null +} + +function namedBindingOf(node: SgNode): { localName: string } | null { + if (!node.is('identifier')) { + return null + } + + const binding = importBindingOf(node) + if (!binding || binding.source !== ATLASSIAN_SOURCE || binding.importedName !== REMOVED_RESOLVER) { + return null + } + + return { localName: binding.localName } +} + +function lineStartOf(source: string, index: number): number { + return source.lastIndexOf('\n', index - 1) + 1 +} + +function lineIndentOf(source: string, index: number): string { + const line = source.slice(lineStartOf(source, index), index) + const match = line.match(/^[ \t]*/) + return match?.[0] ?? '' +} + +function insertTodoAbove(node: SgNode, source: string, edits: Edit[]): void { + const start = node.range().start.index + const lineStart = lineStartOf(source, start) + + // Idempotency: skip when the previous non-empty line already carries the marker. + const previousLineEnd = source.lastIndexOf('\n', Math.max(lineStart - 2, 0)) + const previousLine = source.slice(previousLineEnd + 1, lineStart) + if (previousLine.includes(TODO_MARKER)) { + return + } + + const indent = lineIndentOf(source, start) + edits.push({ + startPos: lineStart, + endPos: lineStart, + insertedText: `${indent}${TODO_LINE}\n`, + }) +} + +function isInsideImport(node: SgNode): boolean { + return node.ancestors().some((ancestor) => ancestor.is('import_statement')) +} + +/** Member expressions `atlassianSignInResolvers.usernameMatchingUserEntityName`. */ +function findNamespaceResolverUses(rootNode: SgNode): SgNode[] { + const uses: SgNode[] = [] + + for (const member of rootNode.findAll({ rule: { kind: 'member_expression' } })) { + if (member.field('property')?.text() !== REMOVED_RESOLVER) { + continue + } + + const objectNode = member.field('object') + if (objectNode && namespaceBindingOf(objectNode)) { + uses.push(member) + } + } + + return uses +} + +/** Reference sites of a directly-named `usernameMatchingUserEntityName` import. */ +function findNamedResolverReferences(rootNode: SgNode): SgNode[] { + const references: SgNode[] = [] + + for (const identifier of rootNode.findAll({ rule: { kind: 'identifier' } })) { + if (identifier.text() !== REMOVED_RESOLVER || isInsideImport(identifier)) { + continue + } + + if (namedBindingOf(identifier)) { + references.push(identifier) + } + } + + return references +} + +interface ImportStatement { + source: string + statement: SgNode +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): ImportStatement[] { + const statements: ImportStatement[] = [] + + for (const statement of rootNode.findAll({ rule: { kind: 'import_statement' } })) { + const sourceNode = statement.find({ rule: { kind: 'string_fragment' } }) + if (sourceNode?.text() === source) { + statements.push({ source, statement }) + } + } + + return statements +} + +/** The import_specifier that imports `usernameMatchingUserEntityName` by name. */ +function findRemovedNamedSpecifier(rootNode: SgNode): SgNode | null { + for (const { statement } of findImportStatementsFrom(rootNode, ATLASSIAN_SOURCE)) { + for (const specifier of statement.findAll({ rule: { kind: 'import_specifier' } })) { + if (specifier.field('name')?.text() === REMOVED_RESOLVER) { + return specifier + } + } + } + + return null +} + +/** The import_specifier that imports `atlassianSignInResolvers` by name. */ +function findNamespaceCollectionSpecifier(rootNode: SgNode): SgNode | null { + for (const { statement } of findImportStatementsFrom(rootNode, ATLASSIAN_SOURCE)) { + for (const specifier of statement.findAll({ rule: { kind: 'import_specifier' } })) { + if (specifier.field('name')?.text() === REMOVED_NAMESPACE) { + return specifier + } + } + } + + return null +} + +/** + * True when `localName` has no remaining uses outside import statements and + * the member expressions this codemod already rewrites. + */ +function isLocalNameUnused(rootNode: SgNode, localName: string, replacedObjectIds: Set): boolean { + for (const identifier of rootNode.findAll({ rule: { kind: 'identifier' } })) { + if (identifier.text() !== localName || isInsideImport(identifier)) { + continue + } + + if (replacedObjectIds.has(identifier.id())) { + continue + } + + return false + } + + return true +} + +/** The namespace_import node (`* as ns`) for the given local name. */ +function findNamespaceImportNode(rootNode: SgNode, localName: string): SgNode | null { + for (const { statement } of findImportStatementsFrom(rootNode, ATLASSIAN_SOURCE)) { + for (const candidate of statement.findAll({ rule: { kind: 'namespace_import' } })) { + const identifier = candidate.children().find((child) => child.is('identifier')) + if (identifier?.text() === localName) { + return candidate + } + } + } + + return null +} + +function removeNamespaceImportEdit(namespaceImport: SgNode): Edit | null { + const statement = namespaceImport.ancestors().find((ancestor) => ancestor.is('import_statement')) + if (!statement) { + return null + } + + const clause = namespaceImport.ancestors().find((ancestor) => ancestor.is('import_clause')) + if (!clause) { + return statement.replace('') + } + + // `import def, * as ns from '...'` keeps the default import. + const defaultIdentifier = clause + .children() + .find((child) => child.is('identifier') && child.range().start.index < namespaceImport.range().start.index) + if (defaultIdentifier) { + return { + startPos: defaultIdentifier.range().end.index, + endPos: namespaceImport.range().end.index, + insertedText: '', + } + } + + return statement.replace('') +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const source = rootNode.text() + const edits: Edit[] = [] + const replacedObjectIds = new Set() + const usedLocalNames = new Set() + let needsReplacementImport = false + + // 1. Replace `atlassianSignInResolvers.usernameMatchingUserEntityName` uses. + const namespaceUses = findNamespaceResolverUses(rootNode) + for (const use of namespaceUses) { + edits.push(use.replace(REPLACEMENT_RESOLVER)) + insertTodoAbove(use, source, edits) + + const objectNode = use.field('object') + const binding = objectNode ? namespaceBindingOf(objectNode) : null + if (objectNode && binding) { + replacedObjectIds.add(objectNode.id()) + usedLocalNames.add(binding.localName) + } + + migrationMetric.increment({ action: 'resolver-replaced', form: 'namespace' }) + } + + // 2. Rename references of a directly-named import. + const namedReferences = findNamedResolverReferences(rootNode) + for (const reference of namedReferences) { + edits.push(reference.replace(REPLACEMENT_RESOLVER)) + insertTodoAbove(reference, source, edits) + migrationMetric.increment({ action: 'resolver-replaced', form: 'named' }) + } + + // 3. Rewrite the directly-named import specifier itself. + const specifier = findRemovedNamedSpecifier(rootNode) + if (specifier) { + const aliasNode = specifier.field('alias') + const replacementText = aliasNode ? `${REPLACEMENT_RESOLVER} as ${aliasNode.text()}` : REPLACEMENT_RESOLVER + edits.push(specifier.replace(replacementText)) + migrationMetric.increment({ action: 'import-rewritten', form: 'named' }) + } + + // 4. Fix up the import of the removed resolver collection. + for (const localName of usedLocalNames) { + if (!isLocalNameUnused(rootNode, localName, replacedObjectIds)) { + // Other members of the collection are still referenced: keep the import + // and add the replacement resolver as its own named import. + needsReplacementImport = true + migrationMetric.increment({ action: 'collection-import-kept' }) + continue + } + + const namespaceImport = findNamespaceImportNode(rootNode, localName) + if (namespaceImport) { + const removal = removeNamespaceImportEdit(namespaceImport) + if (removal) { + edits.push(removal) + migrationMetric.increment({ action: 'namespace-import-removed' }) + } + needsReplacementImport = true + continue + } + + // Named import of the collection: swap the specifier for the replacement. + const collectionSpecifier = findNamespaceCollectionSpecifier(rootNode) + if (collectionSpecifier) { + const aliasNode = collectionSpecifier.field('alias') + const replacementText = aliasNode ? `${REPLACEMENT_RESOLVER} as ${aliasNode.text()}` : REPLACEMENT_RESOLVER + edits.push(collectionSpecifier.replace(replacementText)) + migrationMetric.increment({ action: 'import-rewritten', form: 'collection' }) + } + } + + // 5. Ensure the replacement resolver is imported when we added references. + if (needsReplacementImport) { + const importEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: REPLACEMENT_RESOLVER }], + from: ATLASSIAN_SOURCE, + }) + if (importEdit) { + edits.push(importEdit) + } + } + + if (edits.length === 0) { + return null + } + + const result = await Promise.resolve(rootNode.commitEdits(edits)) + return result +} + +export default transform diff --git a/codemods/v1.56.0/scripts/remove-total-items-option.ts b/codemods/v1.56.0/scripts/remove-total-items-option.ts new file mode 100644 index 00000000..6ba1f63e --- /dev/null +++ b/codemods/v1.56.0/scripts/remove-total-items-option.ts @@ -0,0 +1,191 @@ +import { importBindingOf } from '@jssg/utils/javascript/bindings' +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('remove-total-items-option') + +const STREAM_ENTITIES_FUNCTION = 'streamEntities' +const TOTAL_ITEMS_KEY = 'totalItems' +const REQUEST_TYPE = 'StreamEntitiesRequest' +const REQUEST_TYPE_SOURCES = new Set(['@backstage/catalog-client', '@backstage/plugin-catalog-node']) + +function getPairKeyName(pair: SgNode): string | null { + const keyNode = pair.field('key') + if (!keyNode) { + return null + } + + const kind = keyNode.kind() + if (kind !== 'property_identifier' && kind !== 'string') { + return null + } + + return keyNode.text() +} + +function isHorizontalWhitespace(char: string): boolean { + return char === ' ' || char === '\t' +} + +/** + * Removes a `totalItems` pair from an object literal, absorbing an adjacent + * comma. A single-pair object keeps its `{}` braces. + */ +function removePairEdit(pair: SgNode, source: string, objectNode: SgNode): Edit | null { + const pairs = objectNode.children().filter((child) => child.is('pair')) + if (pairs.length === 1) { + return objectNode.replace('{}') + } + + let startPos = pair.range().start.index + let endPos = pair.range().end.index + + // Absorb a following comma plus the horizontal whitespace after it. + let cursor = endPos + while (cursor < source.length && isHorizontalWhitespace(source.charAt(cursor))) { + cursor += 1 + } + + if (source.charAt(cursor) === ',') { + endPos = cursor + 1 + while (endPos < source.length && isHorizontalWhitespace(source.charAt(endPos))) { + endPos += 1 + } + + // Absorb the following newline boundary and the next line's indent when + // the removed pair started its own line. + if (source.charAt(endPos) === '\r') { + endPos += 1 + } + if (source.charAt(endPos) === '\n') { + endPos += 1 + while (endPos < source.length && isHorizontalWhitespace(source.charAt(endPos))) { + endPos += 1 + } + } + } else { + // Absorb a preceding comma plus the whitespace before the pair. + cursor = startPos + while (cursor > 0 && isHorizontalWhitespace(source.charAt(cursor - 1))) { + cursor -= 1 + } + + if (source.charAt(cursor - 1) !== ',') { + return null + } + + startPos = cursor - 1 + } + + return { startPos, endPos, insertedText: '' } +} + +/** Calls to `streamEntities` or `.streamEntities`. */ +function findStreamEntitiesCalls(rootNode: SgNode): SgNode[] { + const calls: SgNode[] = [] + + for (const call of rootNode.findAll({ rule: { kind: 'call_expression' } })) { + const callee = call.field('function') + if (!callee) { + continue + } + + if (callee.is('identifier') && callee.text() === STREAM_ENTITIES_FUNCTION) { + calls.push(call) + continue + } + + if (callee.is('member_expression')) { + const property = callee.field('property') + if (property.text() === STREAM_ENTITIES_FUNCTION) { + calls.push(call) + } + } + } + + return calls +} + +function isStreamEntitiesRequestType(node: SgNode | null): boolean { + if (!node?.is('type_identifier') || node.text() !== REQUEST_TYPE) { + return false + } + + const binding = importBindingOf(node) + return binding !== null && REQUEST_TYPE_SOURCES.has(binding.source) +} + +/** `const request: StreamEntitiesRequest = { ... }` declarators. */ +function findTypedRequestObjects(rootNode: SgNode): SgNode[] { + const objects: SgNode[] = [] + + for (const declarator of rootNode.findAll({ rule: { kind: 'variable_declarator' } })) { + const typeAnnotation = declarator.find({ rule: { kind: 'type_annotation' } }) + const typeIdentifier = typeAnnotation?.find({ rule: { kind: 'type_identifier' } }) ?? null + if (!isStreamEntitiesRequestType(typeIdentifier)) { + continue + } + + const value = declarator.children().find((child) => child.is('object')) + if (value) { + objects.push(value) + } + } + + return objects +} + +function removeTotalItemsPairs(objectNode: SgNode, source: string, edits: Edit[]): number { + let removed = 0 + + for (const pair of objectNode.children().filter((child) => child.is('pair'))) { + if (getPairKeyName(pair) !== TOTAL_ITEMS_KEY) { + continue + } + + const edit = removePairEdit(pair, source, objectNode) + if (edit) { + edits.push(edit) + removed += 1 + } + } + + return removed +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const source = rootNode.text() + const edits: Edit[] = [] + + for (const call of findStreamEntitiesCalls(rootNode)) { + const argumentsNode = call.field('arguments') + if (!argumentsNode) { + continue + } + + for (const argument of argumentsNode.children().filter((child) => child.is('object'))) { + const removed = removeTotalItemsPairs(argument, source, edits) + if (removed > 0) { + migrationMetric.increment({ action: 'totalItems-removed', context: 'call' }) + } + } + } + + for (const objectNode of findTypedRequestObjects(rootNode)) { + const removed = removeTotalItemsPairs(objectNode, source, edits) + if (removed > 0) { + migrationMetric.increment({ action: 'totalItems-removed', context: 'typed-literal' }) + } + } + + if (edits.length === 0) { + return null + } + + const result = await Promise.resolve(rootNode.commitEdits(edits)) + return result +} + +export default transform diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/already-migrated-noop/expected.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/already-migrated-noop/expected.ts new file mode 100644 index 00000000..f0fd3f2e --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/already-migrated-noop/expected.ts @@ -0,0 +1,11 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class MyFilter implements EntityFilter { + getCatalogFilters() { + return [{ kind: 'component' }]; + } + + getCatalogQuery() { + return { kind: 'component' }; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/already-migrated-noop/input.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/already-migrated-noop/input.ts new file mode 100644 index 00000000..f0fd3f2e --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/already-migrated-noop/input.ts @@ -0,0 +1,11 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class MyFilter implements EntityFilter { + getCatalogFilters() { + return [{ kind: 'component' }]; + } + + getCatalogQuery() { + return { kind: 'component' }; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-any-of-values/expected.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-any-of-values/expected.ts new file mode 100644 index 00000000..e6f3b869 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-any-of-values/expected.ts @@ -0,0 +1,11 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class AnyOfFilter implements EntityFilter { + getCatalogFilters() { + return [{ kind: ['group', 'user'] }, { 'spec.type': 'service' }]; + } + + getCatalogQuery() { + return { $any: [{ $any: [{ kind: 'group' }, { kind: 'user' }] }, { 'spec.type': 'service' }] }; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-any-of-values/input.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-any-of-values/input.ts new file mode 100644 index 00000000..503823f2 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-any-of-values/input.ts @@ -0,0 +1,7 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class AnyOfFilter implements EntityFilter { + getCatalogFilters() { + return [{ kind: ['group', 'user'] }, { 'spec.type': 'service' }]; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-any-of-values/metrics.json b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-any-of-values/metrics.json new file mode 100644 index 00000000..84f42dd5 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-any-of-values/metrics.json @@ -0,0 +1,12 @@ +{ + "migrate-catalog-filters-to-query": [ + { + "cardinality": { + "action": "getCatalogQuery-added", + "context": "class", + "result": "literal" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-computed-todo/expected.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-computed-todo/expected.ts new file mode 100644 index 00000000..f52a34b9 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-computed-todo/expected.ts @@ -0,0 +1,12 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class ComputedFilter implements EntityFilter { + getCatalogFilters() { + return this.buildFilters(); + } + + getCatalogQuery() { + // TODO(backstage-codemod): translate getCatalogFilters to a filter predicate + return undefined; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-computed-todo/input.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-computed-todo/input.ts new file mode 100644 index 00000000..678e3de0 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-computed-todo/input.ts @@ -0,0 +1,7 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class ComputedFilter implements EntityFilter { + getCatalogFilters() { + return this.buildFilters(); + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-computed-todo/metrics.json b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-computed-todo/metrics.json new file mode 100644 index 00000000..4190a3fe --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-computed-todo/metrics.json @@ -0,0 +1,12 @@ +{ + "migrate-catalog-filters-to-query": [ + { + "cardinality": { + "action": "getCatalogQuery-added", + "context": "class", + "result": "todo" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-empty-array/expected.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-empty-array/expected.ts new file mode 100644 index 00000000..1992f122 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-empty-array/expected.ts @@ -0,0 +1,11 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class EmptyFilter implements EntityFilter { + getCatalogFilters() { + return []; + } + + getCatalogQuery() { + return undefined; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-empty-array/input.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-empty-array/input.ts new file mode 100644 index 00000000..91d5c393 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-empty-array/input.ts @@ -0,0 +1,7 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class EmptyFilter implements EntityFilter { + getCatalogFilters() { + return []; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-empty-array/metrics.json b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-empty-array/metrics.json new file mode 100644 index 00000000..84f42dd5 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-empty-array/metrics.json @@ -0,0 +1,12 @@ +{ + "migrate-catalog-filters-to-query": [ + { + "cardinality": { + "action": "getCatalogQuery-added", + "context": "class", + "result": "literal" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-literal-filters/expected.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-literal-filters/expected.ts new file mode 100644 index 00000000..ac454e40 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-literal-filters/expected.ts @@ -0,0 +1,11 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class MyFilter implements EntityFilter { + getCatalogFilters() { + return [{ 'spec.type': 'service', kind: 'component' }, { 'spec.type': 'website' }]; + } + + getCatalogQuery() { + return { $any: [{ $all: [{ 'spec.type': 'service' }, { kind: 'component' }] }, { 'spec.type': 'website' }] }; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-literal-filters/input.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-literal-filters/input.ts new file mode 100644 index 00000000..1a0cbfca --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-literal-filters/input.ts @@ -0,0 +1,7 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class MyFilter implements EntityFilter { + getCatalogFilters() { + return [{ 'spec.type': 'service', kind: 'component' }, { 'spec.type': 'website' }]; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-literal-filters/metrics.json b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-literal-filters/metrics.json new file mode 100644 index 00000000..84f42dd5 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-literal-filters/metrics.json @@ -0,0 +1,12 @@ +{ + "migrate-catalog-filters-to-query": [ + { + "cardinality": { + "action": "getCatalogQuery-added", + "context": "class", + "result": "literal" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-singleton-collapse/expected.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-singleton-collapse/expected.ts new file mode 100644 index 00000000..f7ab2d8e --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-singleton-collapse/expected.ts @@ -0,0 +1,11 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class SingleKindFilter implements EntityFilter { + getCatalogFilters() { + return [{ kind: 'component' }]; + } + + getCatalogQuery() { + return { kind: 'component' }; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-singleton-collapse/input.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-singleton-collapse/input.ts new file mode 100644 index 00000000..a4fd3918 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-singleton-collapse/input.ts @@ -0,0 +1,7 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +class SingleKindFilter implements EntityFilter { + getCatalogFilters() { + return [{ kind: 'component' }]; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-singleton-collapse/metrics.json b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-singleton-collapse/metrics.json new file mode 100644 index 00000000..84f42dd5 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/class-singleton-collapse/metrics.json @@ -0,0 +1,12 @@ +{ + "migrate-catalog-filters-to-query": [ + { + "cardinality": { + "action": "getCatalogQuery-added", + "context": "class", + "result": "literal" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/object-literal-filter/expected.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/object-literal-filter/expected.ts new file mode 100644 index 00000000..c545929f --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/object-literal-filter/expected.ts @@ -0,0 +1,11 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +const filter: EntityFilter = { + getCatalogFilters() { + return [{ kind: 'component' }]; + } + + getCatalogQuery() { + return { kind: 'component' }; + }, +}; diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/object-literal-filter/input.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/object-literal-filter/input.ts new file mode 100644 index 00000000..f912966e --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/object-literal-filter/input.ts @@ -0,0 +1,7 @@ +import type { EntityFilter } from '@backstage/plugin-catalog-react'; + +const filter: EntityFilter = { + getCatalogFilters() { + return [{ kind: 'component' }]; + }, +}; diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/object-literal-filter/metrics.json b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/object-literal-filter/metrics.json new file mode 100644 index 00000000..72b64f19 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/object-literal-filter/metrics.json @@ -0,0 +1,12 @@ +{ + "migrate-catalog-filters-to-query": [ + { + "cardinality": { + "action": "getCatalogQuery-added", + "context": "object", + "result": "literal" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/tsconfig.json b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/tsconfig.json new file mode 100644 index 00000000..2e9025e4 --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "@codemod.com/jssg-types" + ], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "include": [ + "../../scripts/migrate-catalog-filters-to-query.ts" + ] +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/unrelated-filter-noop/expected.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/unrelated-filter-noop/expected.ts new file mode 100644 index 00000000..ae6d9a3f --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/unrelated-filter-noop/expected.ts @@ -0,0 +1,5 @@ +class MyFilter { + getCatalogFilters() { + return [{ kind: 'component' }]; + } +} diff --git a/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/unrelated-filter-noop/input.ts b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/unrelated-filter-noop/input.ts new file mode 100644 index 00000000..ae6d9a3f --- /dev/null +++ b/codemods/v1.56.0/tests/migrate-catalog-filters-to-query/unrelated-filter-noop/input.ts @@ -0,0 +1,5 @@ +class MyFilter { + getCatalogFilters() { + return [{ kind: 'component' }]; + } +} diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/already-migrated-noop/expected.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/already-migrated-noop/expected.tsx new file mode 100644 index 00000000..4a7b7719 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/already-migrated-noop/expected.tsx @@ -0,0 +1,9 @@ +import { Menu, MenuItem, MenuSection } from '@backstage/ui'; + +export const Example = () => ( + + + Edit + + +); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/already-migrated-noop/input.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/already-migrated-noop/input.tsx new file mode 100644 index 00000000..4a7b7719 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/already-migrated-noop/input.tsx @@ -0,0 +1,9 @@ +import { Menu, MenuItem, MenuSection } from '@backstage/ui'; + +export const Example = () => ( + + + Edit + + +); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/dynamic-keys-todo/expected.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/dynamic-keys-todo/expected.tsx new file mode 100644 index 00000000..aa59dbc7 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/dynamic-keys-todo/expected.tsx @@ -0,0 +1,12 @@ +import { MenuItem, MenuSection } from '@backstage/ui'; + +const keys = ['delete']; + +export const Section = () => ( +
+ {/* TODO(backstage-codemod): MenuSection no longer accepts disabledKeys; set isDisabled on the MenuItem elements or disabledKeys on the Menu. Original: disabledKeys={keys} */} + + Edit + +
+); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/dynamic-keys-todo/input.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/dynamic-keys-todo/input.tsx new file mode 100644 index 00000000..3df914a6 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/dynamic-keys-todo/input.tsx @@ -0,0 +1,11 @@ +import { MenuItem, MenuSection } from '@backstage/ui'; + +const keys = ['delete']; + +export const Section = () => ( +
+ + Edit + +
+); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/dynamic-keys-todo/metrics.json b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/dynamic-keys-todo/metrics.json new file mode 100644 index 00000000..6b2daa4c --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/dynamic-keys-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "move-bui-menusection-disabled-keys": [ + { + "cardinality": { + "action": "todo-manual-review", + "reason": "dynamic-keys" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/hoist-to-menu/expected.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/hoist-to-menu/expected.tsx new file mode 100644 index 00000000..257e2756 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/hoist-to-menu/expected.tsx @@ -0,0 +1,10 @@ +import { Menu, MenuItem, MenuSection } from '@backstage/ui'; + +export const Example = () => ( + + + Edit + Delete + + +); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/hoist-to-menu/input.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/hoist-to-menu/input.tsx new file mode 100644 index 00000000..8fed0261 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/hoist-to-menu/input.tsx @@ -0,0 +1,10 @@ +import { Menu, MenuItem, MenuSection } from '@backstage/ui'; + +export const Example = () => ( + + + Edit + Delete + + +); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/hoist-to-menu/metrics.json b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/hoist-to-menu/metrics.json new file mode 100644 index 00000000..9b5f91cf --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/hoist-to-menu/metrics.json @@ -0,0 +1,10 @@ +{ + "move-bui-menusection-disabled-keys": [ + { + "cardinality": { + "action": "disabledKeys-hoisted-to-menu" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/no-menu-isdisabled-fallback/expected.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/no-menu-isdisabled-fallback/expected.tsx new file mode 100644 index 00000000..a734e3dc --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/no-menu-isdisabled-fallback/expected.tsx @@ -0,0 +1,8 @@ +import { MenuItem, MenuSection } from '@backstage/ui'; + +export const Section = () => ( + + Edit + Delete + +); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/no-menu-isdisabled-fallback/input.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/no-menu-isdisabled-fallback/input.tsx new file mode 100644 index 00000000..91bffb0d --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/no-menu-isdisabled-fallback/input.tsx @@ -0,0 +1,8 @@ +import { MenuItem, MenuSection } from '@backstage/ui'; + +export const Section = () => ( + + Edit + Delete + +); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/no-menu-isdisabled-fallback/metrics.json b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/no-menu-isdisabled-fallback/metrics.json new file mode 100644 index 00000000..08aadac7 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/no-menu-isdisabled-fallback/metrics.json @@ -0,0 +1,10 @@ +{ + "move-bui-menusection-disabled-keys": [ + { + "cardinality": { + "action": "menuItem-isDisabled-set" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/other-package-noop/expected.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/other-package-noop/expected.tsx new file mode 100644 index 00000000..37d75b09 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/other-package-noop/expected.tsx @@ -0,0 +1,5 @@ +import { MenuSection } from './local-menu'; + +export const Example = () => ( + +); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/other-package-noop/input.tsx b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/other-package-noop/input.tsx new file mode 100644 index 00000000..37d75b09 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/other-package-noop/input.tsx @@ -0,0 +1,5 @@ +import { MenuSection } from './local-menu'; + +export const Example = () => ( + +); diff --git a/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/tsconfig.json b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/tsconfig.json new file mode 100644 index 00000000..f3eb4854 --- /dev/null +++ b/codemods/v1.56.0/tests/move-bui-menusection-disabled-keys/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "@codemod.com/jssg-types" + ], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "include": [ + "../../scripts/move-bui-menusection-disabled-keys.ts" + ] +} diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/atlassian-resolver-flagged/expected.yaml b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/atlassian-resolver-flagged/expected.yaml new file mode 100644 index 00000000..44557400 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/atlassian-resolver-flagged/expected.yaml @@ -0,0 +1,6 @@ +auth: + providers: + atlassian: + signIn: + # TODO(backstage-codemod): replace usernameMatchingUserEntityName with emailLocalPartMatchingUserEntityName or emailMatchingUserEntityProfileEmail + resolver: usernameMatchingUserEntityName diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/atlassian-resolver-flagged/input.yaml b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/atlassian-resolver-flagged/input.yaml new file mode 100644 index 00000000..0fdd52c3 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/atlassian-resolver-flagged/input.yaml @@ -0,0 +1,5 @@ +auth: + providers: + atlassian: + signIn: + resolver: usernameMatchingUserEntityName diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/atlassian-resolver-flagged/metrics.json b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/atlassian-resolver-flagged/metrics.json new file mode 100644 index 00000000..20a31c62 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/atlassian-resolver-flagged/metrics.json @@ -0,0 +1,10 @@ +{ + "remove-atlassian-username-resolver-yaml": [ + { + "cardinality": { + "action": "resolver-flagged" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/idempotent-existing-todo/expected.yaml b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/idempotent-existing-todo/expected.yaml new file mode 100644 index 00000000..44557400 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/idempotent-existing-todo/expected.yaml @@ -0,0 +1,6 @@ +auth: + providers: + atlassian: + signIn: + # TODO(backstage-codemod): replace usernameMatchingUserEntityName with emailLocalPartMatchingUserEntityName or emailMatchingUserEntityProfileEmail + resolver: usernameMatchingUserEntityName diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/idempotent-existing-todo/input.yaml b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/idempotent-existing-todo/input.yaml new file mode 100644 index 00000000..44557400 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/idempotent-existing-todo/input.yaml @@ -0,0 +1,6 @@ +auth: + providers: + atlassian: + signIn: + # TODO(backstage-codemod): replace usernameMatchingUserEntityName with emailLocalPartMatchingUserEntityName or emailMatchingUserEntityProfileEmail + resolver: usernameMatchingUserEntityName diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/idempotent-existing-todo/metrics.json b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/idempotent-existing-todo/metrics.json new file mode 100644 index 00000000..20a31c62 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/idempotent-existing-todo/metrics.json @@ -0,0 +1,10 @@ +{ + "remove-atlassian-username-resolver-yaml": [ + { + "cardinality": { + "action": "resolver-flagged" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/other-provider-noop/expected.yaml b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/other-provider-noop/expected.yaml new file mode 100644 index 00000000..941c1fc1 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/other-provider-noop/expected.yaml @@ -0,0 +1,5 @@ +auth: + providers: + github: + signIn: + resolver: usernameMatchingUserEntityName diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/other-provider-noop/input.yaml b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/other-provider-noop/input.yaml new file mode 100644 index 00000000..941c1fc1 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/other-provider-noop/input.yaml @@ -0,0 +1,5 @@ +auth: + providers: + github: + signIn: + resolver: usernameMatchingUserEntityName diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/tsconfig.json b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/tsconfig.json new file mode 100644 index 00000000..6caa9f9c --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver-yaml/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "@codemod.com/jssg-types" + ], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "include": [ + "../../scripts/remove-atlassian-username-resolver-yaml.ts" + ] +} diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/named-import-rename/expected.ts b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/named-import-rename/expected.ts new file mode 100644 index 00000000..92063b3a --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/named-import-rename/expected.ts @@ -0,0 +1,12 @@ +import { emailLocalPartMatchingUserEntityName } from '@backstage/plugin-auth-backend-module-atlassian-provider'; + +export const auth = { + providers: { + atlassian: { + signIn: { + // TODO(backstage-codemod): consider emailMatchingUserEntityProfileEmail instead + resolver: emailLocalPartMatchingUserEntityName, + }, + }, + }, +}; diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/named-import-rename/input.ts b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/named-import-rename/input.ts new file mode 100644 index 00000000..7bde84ec --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/named-import-rename/input.ts @@ -0,0 +1,11 @@ +import { usernameMatchingUserEntityName } from '@backstage/plugin-auth-backend-module-atlassian-provider'; + +export const auth = { + providers: { + atlassian: { + signIn: { + resolver: usernameMatchingUserEntityName, + }, + }, + }, +}; diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/named-import-rename/metrics.json b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/named-import-rename/metrics.json new file mode 100644 index 00000000..f7ecb083 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/named-import-rename/metrics.json @@ -0,0 +1,18 @@ +{ + "remove-atlassian-username-resolver": [ + { + "cardinality": { + "action": "import-rewritten", + "form": "named" + }, + "count": 1 + }, + { + "cardinality": { + "action": "resolver-replaced", + "form": "named" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-resolver-replaced/expected.ts b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-resolver-replaced/expected.ts new file mode 100644 index 00000000..92063b3a --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-resolver-replaced/expected.ts @@ -0,0 +1,12 @@ +import { emailLocalPartMatchingUserEntityName } from '@backstage/plugin-auth-backend-module-atlassian-provider'; + +export const auth = { + providers: { + atlassian: { + signIn: { + // TODO(backstage-codemod): consider emailMatchingUserEntityProfileEmail instead + resolver: emailLocalPartMatchingUserEntityName, + }, + }, + }, +}; diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-resolver-replaced/input.ts b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-resolver-replaced/input.ts new file mode 100644 index 00000000..9ec51205 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-resolver-replaced/input.ts @@ -0,0 +1,11 @@ +import { atlassianSignInResolvers } from '@backstage/plugin-auth-backend-module-atlassian-provider'; + +export const auth = { + providers: { + atlassian: { + signIn: { + resolver: atlassianSignInResolvers.usernameMatchingUserEntityName, + }, + }, + }, +}; diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-resolver-replaced/metrics.json b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-resolver-replaced/metrics.json new file mode 100644 index 00000000..d60bd7a5 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-resolver-replaced/metrics.json @@ -0,0 +1,18 @@ +{ + "remove-atlassian-username-resolver": [ + { + "cardinality": { + "action": "import-rewritten", + "form": "collection" + }, + "count": 1 + }, + { + "cardinality": { + "action": "resolver-replaced", + "form": "namespace" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-still-used-elsewhere/expected.ts b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-still-used-elsewhere/expected.ts new file mode 100644 index 00000000..5711dabd --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-still-used-elsewhere/expected.ts @@ -0,0 +1,15 @@ +import * as atlassianSignInResolvers from '@backstage/plugin-auth-backend-module-atlassian-provider'; +import { emailLocalPartMatchingUserEntityName } from '@backstage/plugin-auth-backend-module-atlassian-provider'; + +export const auth = { + providers: { + atlassian: { + signIn: { + // TODO(backstage-codemod): consider emailMatchingUserEntityProfileEmail instead + resolver: emailLocalPartMatchingUserEntityName, + }, + }, + }, +}; + +export const fallback = atlassianSignInResolvers.emailMatchingUserEntityProfileEmail; diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-still-used-elsewhere/input.ts b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-still-used-elsewhere/input.ts new file mode 100644 index 00000000..151c0126 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-still-used-elsewhere/input.ts @@ -0,0 +1,13 @@ +import * as atlassianSignInResolvers from '@backstage/plugin-auth-backend-module-atlassian-provider'; + +export const auth = { + providers: { + atlassian: { + signIn: { + resolver: atlassianSignInResolvers.usernameMatchingUserEntityName, + }, + }, + }, +}; + +export const fallback = atlassianSignInResolvers.emailMatchingUserEntityProfileEmail; diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-still-used-elsewhere/metrics.json b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-still-used-elsewhere/metrics.json new file mode 100644 index 00000000..5a13c5e3 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/namespace-still-used-elsewhere/metrics.json @@ -0,0 +1,17 @@ +{ + "remove-atlassian-username-resolver": [ + { + "cardinality": { + "action": "collection-import-kept" + }, + "count": 1 + }, + { + "cardinality": { + "action": "resolver-replaced", + "form": "namespace" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/noop-other-package/expected.ts b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/noop-other-package/expected.ts new file mode 100644 index 00000000..82d4eea1 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/noop-other-package/expected.ts @@ -0,0 +1,11 @@ +import { atlassianSignInResolvers } from './local-resolvers'; + +export const auth = { + providers: { + atlassian: { + signIn: { + resolver: atlassianSignInResolvers.usernameMatchingUserEntityName, + }, + }, + }, +}; diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/noop-other-package/input.ts b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/noop-other-package/input.ts new file mode 100644 index 00000000..82d4eea1 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/noop-other-package/input.ts @@ -0,0 +1,11 @@ +import { atlassianSignInResolvers } from './local-resolvers'; + +export const auth = { + providers: { + atlassian: { + signIn: { + resolver: atlassianSignInResolvers.usernameMatchingUserEntityName, + }, + }, + }, +}; diff --git a/codemods/v1.56.0/tests/remove-atlassian-username-resolver/tsconfig.json b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/tsconfig.json new file mode 100644 index 00000000..eba422c9 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-atlassian-username-resolver/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "@codemod.com/jssg-types" + ], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "include": [ + "../../scripts/remove-atlassian-username-resolver.ts" + ] +} diff --git a/codemods/v1.56.0/tests/remove-total-items-option/call-option-removed/expected.ts b/codemods/v1.56.0/tests/remove-total-items-option/call-option-removed/expected.ts new file mode 100644 index 00000000..e2efe1db --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/call-option-removed/expected.ts @@ -0,0 +1 @@ +const { items } = await catalogClient.streamEntities(request, {}); diff --git a/codemods/v1.56.0/tests/remove-total-items-option/call-option-removed/input.ts b/codemods/v1.56.0/tests/remove-total-items-option/call-option-removed/input.ts new file mode 100644 index 00000000..479a0856 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/call-option-removed/input.ts @@ -0,0 +1 @@ +const { items } = await catalogClient.streamEntities(request, { totalItems: true }); diff --git a/codemods/v1.56.0/tests/remove-total-items-option/call-option-removed/metrics.json b/codemods/v1.56.0/tests/remove-total-items-option/call-option-removed/metrics.json new file mode 100644 index 00000000..384a50f3 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/call-option-removed/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-total-items-option": [ + { + "cardinality": { + "action": "totalItems-removed", + "context": "call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/remove-total-items-option/call-option-with-others/expected.ts b/codemods/v1.56.0/tests/remove-total-items-option/call-option-with-others/expected.ts new file mode 100644 index 00000000..ae525d27 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/call-option-with-others/expected.ts @@ -0,0 +1,3 @@ +const { items } = await catalogClient.streamEntities(request, { + limit: 100, +}); diff --git a/codemods/v1.56.0/tests/remove-total-items-option/call-option-with-others/input.ts b/codemods/v1.56.0/tests/remove-total-items-option/call-option-with-others/input.ts new file mode 100644 index 00000000..f2f7cfcc --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/call-option-with-others/input.ts @@ -0,0 +1,4 @@ +const { items } = await catalogClient.streamEntities(request, { + totalItems: true, + limit: 100, +}); diff --git a/codemods/v1.56.0/tests/remove-total-items-option/call-option-with-others/metrics.json b/codemods/v1.56.0/tests/remove-total-items-option/call-option-with-others/metrics.json new file mode 100644 index 00000000..384a50f3 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/call-option-with-others/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-total-items-option": [ + { + "cardinality": { + "action": "totalItems-removed", + "context": "call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/remove-total-items-option/response-totalitems-noop/expected.ts b/codemods/v1.56.0/tests/remove-total-items-option/response-totalitems-noop/expected.ts new file mode 100644 index 00000000..50071914 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/response-totalitems-noop/expected.ts @@ -0,0 +1,6 @@ +const response = { totalItems: 5, items: [] }; +const total = response.totalItems; + +function makeResponse(totalItems: number): GetEntitiesResponse { + return { totalItems, items: [] }; +} diff --git a/codemods/v1.56.0/tests/remove-total-items-option/response-totalitems-noop/input.ts b/codemods/v1.56.0/tests/remove-total-items-option/response-totalitems-noop/input.ts new file mode 100644 index 00000000..50071914 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/response-totalitems-noop/input.ts @@ -0,0 +1,6 @@ +const response = { totalItems: 5, items: [] }; +const total = response.totalItems; + +function makeResponse(totalItems: number): GetEntitiesResponse { + return { totalItems, items: [] }; +} diff --git a/codemods/v1.56.0/tests/remove-total-items-option/trailing-option-removed/expected.ts b/codemods/v1.56.0/tests/remove-total-items-option/trailing-option-removed/expected.ts new file mode 100644 index 00000000..5f73cd77 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/trailing-option-removed/expected.ts @@ -0,0 +1 @@ +await client.streamEntities(request, { limit: 100 }); diff --git a/codemods/v1.56.0/tests/remove-total-items-option/trailing-option-removed/input.ts b/codemods/v1.56.0/tests/remove-total-items-option/trailing-option-removed/input.ts new file mode 100644 index 00000000..881d4028 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/trailing-option-removed/input.ts @@ -0,0 +1 @@ +await client.streamEntities(request, { limit: 100, totalItems: true }); diff --git a/codemods/v1.56.0/tests/remove-total-items-option/trailing-option-removed/metrics.json b/codemods/v1.56.0/tests/remove-total-items-option/trailing-option-removed/metrics.json new file mode 100644 index 00000000..384a50f3 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/trailing-option-removed/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-total-items-option": [ + { + "cardinality": { + "action": "totalItems-removed", + "context": "call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/remove-total-items-option/tsconfig.json b/codemods/v1.56.0/tests/remove-total-items-option/tsconfig.json new file mode 100644 index 00000000..682a990b --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "@codemod.com/jssg-types" + ], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "include": [ + "../../scripts/remove-total-items-option.ts" + ] +} diff --git a/codemods/v1.56.0/tests/remove-total-items-option/typed-literal-removed/expected.ts b/codemods/v1.56.0/tests/remove-total-items-option/typed-literal-removed/expected.ts new file mode 100644 index 00000000..b2fa2d54 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/typed-literal-removed/expected.ts @@ -0,0 +1,5 @@ +import type { StreamEntitiesRequest } from '@backstage/catalog-client'; + +const request: StreamEntitiesRequest = { limit: 100 }; + +await catalogClient.streamEntities(request); diff --git a/codemods/v1.56.0/tests/remove-total-items-option/typed-literal-removed/input.ts b/codemods/v1.56.0/tests/remove-total-items-option/typed-literal-removed/input.ts new file mode 100644 index 00000000..c75b32f6 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/typed-literal-removed/input.ts @@ -0,0 +1,5 @@ +import type { StreamEntitiesRequest } from '@backstage/catalog-client'; + +const request: StreamEntitiesRequest = { totalItems: true, limit: 100 }; + +await catalogClient.streamEntities(request); diff --git a/codemods/v1.56.0/tests/remove-total-items-option/typed-literal-removed/metrics.json b/codemods/v1.56.0/tests/remove-total-items-option/typed-literal-removed/metrics.json new file mode 100644 index 00000000..c492b281 --- /dev/null +++ b/codemods/v1.56.0/tests/remove-total-items-option/typed-literal-removed/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-total-items-option": [ + { + "cardinality": { + "action": "totalItems-removed", + "context": "typed-literal" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1.56.0/tests/workflow.test.ts b/codemods/v1.56.0/tests/workflow.test.ts new file mode 100644 index 00000000..3f4bdfe4 --- /dev/null +++ b/codemods/v1.56.0/tests/workflow.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { parse } from 'yaml' + +test('v1.56.0 runs all local transforms in migration order', () => { + const workflow = parse(readFileSync('workflow.yaml', 'utf8')) + assert.deepEqual(workflow.nodes.map(node => node.id), ['apply-recipe']) + const transforms = workflow.nodes[0].steps.map(step => step['js-ast-grep']) + assert.deepEqual(transforms.map(step => step.js_file), [ + 'scripts/migrate-catalog-filters-to-query.ts', + 'scripts/remove-atlassian-username-resolver.ts', + 'scripts/remove-atlassian-username-resolver-yaml.ts', + 'scripts/remove-total-items-option.ts', + 'scripts/move-bui-menusection-disabled-keys.ts', + ]) + assert.deepEqual(transforms.map(step => step.language), ['tsx', 'tsx', 'yaml', 'tsx', 'tsx']) + assert.deepEqual(transforms[2].include, [ + '**/app-config.yaml', + '**/app-config.*.yaml', + '**/app-config.yml', + '**/app-config.*.yml', + ]) + for (const step of transforms) { + assert.deepEqual(step.exclude, ['**/node_modules/**']) + if (step.language === 'tsx') { + assert.equal(step.semantic_analysis, 'file') + assert.deepEqual(step.include, ['**/*.ts', '**/*.tsx']) + } + } +}) diff --git a/codemods/v1.56.0/tsconfig.json b/codemods/v1.56.0/tsconfig.json new file mode 100644 index 00000000..beca2b20 --- /dev/null +++ b/codemods/v1.56.0/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "allowJs": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/v1.56.0/workflow.yaml b/codemods/v1.56.0/workflow.yaml new file mode 100644 index 00000000..ae018aff --- /dev/null +++ b/codemods/v1.56.0/workflow.yaml @@ -0,0 +1,68 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-recipe + name: 'Run every Backstage 1.56.0 codemod in order' + type: automatic + steps: + # --- DEPRECATION: EntityFilter.getCatalogQuery alongside getCatalogFilters + - name: 'Add getCatalogQuery to custom EntityFilter implementations' + js-ast-grep: + js_file: scripts/migrate-catalog-filters-to-query.ts + language: 'tsx' + semantic_analysis: 'file' + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' + + # --- BREAKING: removed Atlassian username sign-in resolver + - name: 'Replace removed Atlassian username resolver' + js-ast-grep: + js_file: scripts/remove-atlassian-username-resolver.ts + language: 'tsx' + semantic_analysis: 'file' + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' + + - name: 'Flag removed Atlassian username resolver in app-config' + js-ast-grep: + js_file: scripts/remove-atlassian-username-resolver-yaml.ts + language: 'yaml' + include: + - '**/app-config.yaml' + - '**/app-config.*.yaml' + - '**/app-config.yml' + - '**/app-config.*.yml' + exclude: + - '**/node_modules/**' + + # --- DEPRECATION: ignored totalItems option of StreamEntitiesRequest + - name: 'Remove ignored totalItems option from streamEntities requests' + js-ast-grep: + js_file: scripts/remove-total-items-option.ts + language: 'tsx' + semantic_analysis: 'file' + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' + + # --- BREAKING: BUI MenuSection no longer accepts disabledKeys + - name: 'Move BUI MenuSection disabledKeys to Menu' + js-ast-grep: + js_file: scripts/move-bui-menusection-disabled-keys.ts + language: 'tsx' + semantic_analysis: 'file' + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/yarn.lock b/yarn.lock index 572cf880..86683389 100644 --- a/yarn.lock +++ b/yarn.lock @@ -219,6 +219,17 @@ __metadata: languageName: unknown linkType: soft +"@backstage/v1-56-0-migration-recipe@workspace:codemods/v1.56.0": + version: 0.0.0-use.local + resolution: "@backstage/v1-56-0-migration-recipe@workspace:codemods/v1.56.0" + dependencies: + "@backstage/scripts": "workspace:*" + "@codemod.com/jssg-types": "npm:1.6.3" + "@jssg/utils": "patch:@jssg/utils@npm%3A0.0.11#~/.yarn/patches/@jssg-utils-npm-0.0.11-19369c37c8.patch" + codemod: "npm:1.18.3" + languageName: unknown + linkType: soft + "@backstage/version-bridge@npm:^1.0.12": version: 1.0.12 resolution: "@backstage/version-bridge@npm:1.0.12"