github: stabilize entity order in multi-org provider

When using GithubMultiOrgEntityProvider with alwaysUseDefaultNamespace
and teams with identical slugs across orgs, the emitted entity order
was non-deterministic. Since the catalog's upsert path uses last-write-
wins for duplicate entity refs, this caused the winning org to flip
randomly on every refresh cycle, producing constant unnecessary
stitching and flickering entity data.

Sort the emitted entities by entity ref (primary) and location
annotation (tiebreaker) so that the same org consistently wins when
duplicate refs exist.

Fixes #34263

Signed-off-by: Fredrik Adelöw <freben@gmail.com>

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-05-15 14:07:48 +02:00
parent 0f1f4392d6
commit aa313f099c
3 changed files with 35 additions and 13 deletions
+5
View File
@@ -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.
@@ -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',
});
});
@@ -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();