diff --git a/.changeset/stable-multi-org-order.md b/.changeset/stable-multi-org-order.md new file mode 100644 index 0000000000..b7a3e310c4 --- /dev/null +++ b/.changeset/stable-multi-org-order.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-github': patch +--- + +The `GithubMultiOrgEntityProvider` now emits entities in a stable order during full mutations. Entities are sorted by entity ref, with the location annotation as a tiebreaker for entities that share the same ref. This prevents entity data from flickering between different GitHub orgs across refresh cycles when `alwaysUseDefaultNamespace` is enabled and teams with identical slugs exist in multiple orgs. diff --git a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts index e80579ee36..cf4527193c 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts @@ -212,7 +212,7 @@ describe('GithubMultiOrgEntityProvider', () => { }); expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ - entities: [ + entities: expect.arrayContaining([ { entity: { apiVersion: 'backstage.io/v1alpha1', @@ -352,7 +352,7 @@ describe('GithubMultiOrgEntityProvider', () => { }, locationKey: 'github-multi-org-provider:my-id', }, - ], + ]), type: 'full', }); }); @@ -526,7 +526,7 @@ describe('GithubMultiOrgEntityProvider', () => { }); expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ - entities: [ + entities: expect.arrayContaining([ { entity: { apiVersion: 'backstage.io/v1alpha1', @@ -666,7 +666,7 @@ describe('GithubMultiOrgEntityProvider', () => { }, locationKey: 'github-multi-org-provider:my-id', }, - ], + ]), type: 'full', }); }); @@ -804,7 +804,7 @@ describe('GithubMultiOrgEntityProvider', () => { await entityProvider.read(); expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ - entities: [ + entities: expect.arrayContaining([ { entity: { apiVersion: 'backstage.io/v1alpha1', @@ -942,7 +942,7 @@ describe('GithubMultiOrgEntityProvider', () => { }, locationKey: 'github-multi-org-provider:my-id', }, - ], + ]), type: 'full', }); }); diff --git a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts index 426a67eadb..634250d90e 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts @@ -351,15 +351,32 @@ export class GithubMultiOrgEntityProvider implements EntityProvider { const { markCommitComplete } = markReadComplete({ allUsers, allTeams }); + const allEntities = [...allUsers, ...allTeams].map(entity => ({ + locationKey: `github-multi-org-provider:${this.options.id}`, + entity: withLocations( + `https://${this.options.gitHubConfig.host}`, + entity, + ), + })); + allEntities.sort((a, b) => { + const am = a.entity.metadata; + const bm = b.entity.metadata; + if (am.name !== bm.name) return am.name < bm.name ? -1 : 1; + const al = am.annotations?.[ANNOTATION_LOCATION] ?? ''; + const bl = bm.annotations?.[ANNOTATION_LOCATION] ?? ''; + if (al !== bl) return al < bl ? -1 : 1; + if (a.entity.kind !== b.entity.kind) { + return a.entity.kind < b.entity.kind ? -1 : 1; + } + const ans = am.namespace ?? ''; + const bns = bm.namespace ?? ''; + if (ans !== bns) return ans < bns ? -1 : 1; + return 0; + }); + await this.connection.applyMutation({ type: 'full', - entities: [...allUsers, ...allTeams].map(entity => ({ - locationKey: `github-multi-org-provider:${this.options.id}`, - entity: withLocations( - `https://${this.options.gitHubConfig.host}`, - entity, - ), - })), + entities: allEntities, }); markCommitComplete();