Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/witty-pans-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/v1-56-0-migration-recipe': minor
---

Add Backstage 1.56.0 migration recipe
66 changes: 66 additions & 0 deletions codemods/v1.56.0/README.md
Original file line number Diff line number Diff line change
@@ -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 <your-workflow.yaml> --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.
19 changes: 19 additions & 0 deletions codemods/v1.56.0/codemod.yaml
Original file line number Diff line number Diff line change
@@ -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<pschultz@redhat.com>'
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: []
17 changes: 17 additions & 0 deletions codemods/v1.56.0/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
262 changes: 262 additions & 0 deletions codemods/v1.56.0/scripts/migrate-catalog-filters-to-query.ts
Original file line number Diff line number Diff line change
@@ -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<TSX> | 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<TSX>): 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<TSX>): 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<TSX>): 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<TSX>): boolean {
const kind = node.kind()
return (
kind === 'string' ||
kind === 'number' ||
kind === 'true' ||
kind === 'false' ||
kind === 'null' ||
kind === 'undefined'
)
}

function arrayElements(arrayNode: SgNode<TSX>): SgNode<TSX>[] {
return arrayNode.children().filter((child) => ![',', '[', ']'].includes(child.kind()))
}

/** Builds `{ key: value }` or `{ $any: [{ key: v1 }, { key: v2 }] }` for one pair. */
function pairCondition(pair: SgNode<TSX>): 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<TSX>): 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<TSX>): 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<TSX>): SgNode<TSX> | 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<TSX> = 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
Loading
Loading